auto: 일일 백업 2026-09-22 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""KRX 애프터마켓(16:00~20:00, 2026-09-14 시행) 실측 probe — **조회 전용, 일회성**.
|
||||
|
||||
세션 모델을 고치기 전에 확인해야 할 4가지를 시각대별로 스냅샷으로 남긴다.
|
||||
분석이 끝나면 이 스크립트와 plist(ai.openclaw.stock.aftermarket-probe)를 지운다.
|
||||
|
||||
확인 대상:
|
||||
1. 애프터마켓 체결가가 KRX / NXT / SOR(AL) 중 어디로 오는가 (ka10095 3거래소 동시 조회)
|
||||
2. 애프터마켓 가격제한폭이 정규장과 같은가 (`upl_pric`/`lst_pric` 시각대별 비교)
|
||||
3. KRX 정규장 미체결이 15:35 증거금 해지 후 ka10075 에서 빠지는가
|
||||
4. 애프터마켓에 호가가 뜨는가 (ka10004 bare=KRX / _AL)
|
||||
|
||||
⚠️ 주문 경로를 일절 건드리지 않는다 — orders 패키지를 import 하지 않고 kiwoom_client 만 쓴다.
|
||||
⚠️ 응답을 **원시 그대로** 저장한다. 목적이 '새 필드가 생겼는지' 확인이라 정규화하면 안 된다.
|
||||
⚠️ 한 항목이 실패해도 나머지는 계속 수집한다 (에러도 같이 기록).
|
||||
|
||||
결과: state/aftermarket_probe.jsonl — 1회차 = 1줄
|
||||
사용: python3 aftermarket_probe.py [--tag <라벨>]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
|
||||
sys.path.insert(0, str(WORKSPACE / 'scripts'))
|
||||
|
||||
import kiwoom_client as kc # noqa: E402
|
||||
|
||||
OUT = WORKSPACE / 'state' / 'aftermarket_probe.jsonl'
|
||||
|
||||
# NXT 미상장 일반주 = 애프터마켓에서 새로 거래 가능해지는 쪽 (관심의 핵심)
|
||||
# NXT 상장 = 기존에도 15:30 이후 거래되던 쪽 (대조)
|
||||
# ETF = 애프터마켓 거래불가로 공지된 쪽 (대조)
|
||||
TARGETS = [
|
||||
('005935', '삼성전자우', 'nxt_미상장'),
|
||||
('064400', 'LG씨엔에스', 'nxt_미상장'),
|
||||
('381620', '제닉스로보틱스', 'nxt_미상장'),
|
||||
('025980', '아난티', 'nxt_미상장'),
|
||||
('005930', '삼성전자', 'nxt_상장'),
|
||||
('000660', 'SK하이닉스', 'nxt_상장'),
|
||||
('102110', 'TIGER 200', 'etf'),
|
||||
]
|
||||
BOOK_CODES = ['005935', '005930', '102110'] # 호가는 유형별 대표 1종목씩만
|
||||
|
||||
|
||||
def _safe(fn, *a, **kw):
|
||||
"""수집 실패를 예외로 흘리지 않는다 — 한 항목 실패가 회차 전체를 날리면 안 된다."""
|
||||
try:
|
||||
return {'ok': True, 'data': fn(*a, **kw)}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'error': f'{e!r}', 'trace': traceback.format_exc(limit=3)}
|
||||
|
||||
|
||||
def _raw_ka10095(codes: list[str], suffix: str) -> dict:
|
||||
"""ka10095 원시 응답. get_watchlist_quotes 는 필드를 추려버려서 직접 부른다."""
|
||||
label = kc._default_account_label()
|
||||
param = '|'.join(f'{c}{suffix}' for c in codes)
|
||||
return kc._call(label, kc.TR_WATCHLIST_INFO, {'stk_cd': param},
|
||||
endpoint=kc.ENDPOINT_STKINFO)
|
||||
|
||||
|
||||
def _raw_ka10004(code: str, suffix: str) -> dict:
|
||||
label = kc._default_account_label()
|
||||
url = kc.base_url() + kc.ENDPOINT_MRKCOND
|
||||
resp, _ = kc._http_post_full(url, {'stk_cd': f'{code}{suffix}'},
|
||||
kc.auth_headers(label, tr_id='ka10004'))
|
||||
return resp
|
||||
|
||||
|
||||
def collect(tag: str) -> dict:
|
||||
now = datetime.now(KST)
|
||||
codes = [c for c, _, _ in TARGETS]
|
||||
rec: dict = {
|
||||
'ts': now.isoformat(),
|
||||
'tag': tag,
|
||||
'targets': [{'code': c, 'name': n, 'kind': k} for c, n, k in TARGETS],
|
||||
'quotes': {},
|
||||
'books': {},
|
||||
'open_orders': None,
|
||||
}
|
||||
|
||||
# 1·2번 — 같은 종목을 세 거래소로 동시에 물어 값이 갈리는지 본다
|
||||
for ex_name, suffix in (('KRX', ''), ('NXT', '_NX'), ('SOR', '_AL')):
|
||||
rec['quotes'][ex_name] = _safe(_raw_ka10095, codes, suffix)
|
||||
|
||||
# 4번 — 애프터마켓에 호가가 실리는지
|
||||
for code in BOOK_CODES:
|
||||
for ex_name, suffix in (('KRX', ''), ('SOR', '_AL')):
|
||||
rec['books'][f'{code}:{ex_name}'] = _safe(_raw_ka10004, code, suffix)
|
||||
|
||||
# 3번 — KRX 미체결 생존 (증거금 해지 15:35 / NXT 20:05)
|
||||
rec['open_orders'] = _safe(kc.get_open_orders_all)
|
||||
|
||||
return rec
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tag = 'manual'
|
||||
if '--tag' in sys.argv:
|
||||
tag = sys.argv[sys.argv.index('--tag') + 1]
|
||||
rec = collect(tag)
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
with OUT.open('a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(rec, ensure_ascii=False) + '\n')
|
||||
|
||||
ok_q = sum(1 for v in rec['quotes'].values() if v['ok'])
|
||||
ok_b = sum(1 for v in rec['books'].values() if v['ok'])
|
||||
oo = rec['open_orders']
|
||||
n_oo = len(oo['data']) if oo['ok'] else -1
|
||||
print(f"[{rec['ts']}] tag={tag} 시세 {ok_q}/3 · 호가 {ok_b}/{len(rec['books'])} · 미체결 {n_oo}건 → {OUT}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -789,10 +789,20 @@ def _market_phase_state() -> dict:
|
||||
"""현재 시각 기준 시장 phase 판정. 거래 모달 시간 가드용.
|
||||
|
||||
- regular(정규장): 평일 09:00~15:30 — KRX+NXT 거래 가능
|
||||
- nxt(NXT): 평일 08:00~09:00 + 15:30~20:00 — NXT만 거래 가능
|
||||
- nxt(NXT): 평일 08:00~09:00(프리) + 15:30~16:00 — NXT만 거래 가능
|
||||
- after(애프터마켓): 평일 16:00~20:00 — **KRX 애프터마켓 + NXT 애프터마켓** (2026-09-14 시행)
|
||||
- closed(장외): 평일 그 외 — 매매 불가
|
||||
- holiday(휴장): KRX 휴장일 — 매매 불가
|
||||
- weekend(주말): 토·일 — 매매 불가
|
||||
|
||||
⚠️ `after` 를 `nxt` 와 나눠야 하는 이유 — 2026-09-14 KRX 애프터마켓 신설로 **NXT 미상장
|
||||
종목도 16:00 이후 거래된다**(2026-09-21 실측: 삼성전자우 KRX 거래량 16:05→18:00
|
||||
+134,198주). 한 값으로 뭉치면 `nxt_enable=False` 인 2,600여 종목을 부당하게 잠근다
|
||||
(실측 당시 보유 16·관심 26·감시 46종목이 "값은 갱신되는데 버튼은 잠긴" 상태였다).
|
||||
⚠️ 15:30~16:00 은 `nxt` 로 남긴다 — KRX 쪽은 시간외 종가매매(15:40~16:00)뿐이고
|
||||
우리는 그 주문유형(`trde_tp=81`)을 쓰지 않으므로 "NXT 만 거래 가능"이 맞다.
|
||||
⚠️ `after` 를 새로 넣을 때 **`== 'nxt'` 로 비교하는 소비처를 전부 같이 고쳐야 한다** —
|
||||
안 고치면 else 로 떨어져 실시간 가격 보정·거래가능 초록점·OHLC 활성열이 조용히 죽는다.
|
||||
"""
|
||||
now = datetime.now(KST)
|
||||
time_str = now.strftime('%H:%M')
|
||||
@@ -803,13 +813,18 @@ def _market_phase_state() -> dict:
|
||||
hm = now.hour * 60 + now.minute
|
||||
if 9 * 60 <= hm < 15 * 60 + 30:
|
||||
return {'phase': 'regular', 'label': '정규장', 'active': True, 'time': time_str}
|
||||
if 16 * 60 <= hm < 20 * 60:
|
||||
return {'phase': 'after', 'label': '애프터마켓', 'active': True, 'time': time_str}
|
||||
if 8 * 60 <= hm < 20 * 60:
|
||||
return {'phase': 'nxt', 'label': 'NXT 시장', 'active': True, 'time': time_str}
|
||||
return {'phase': 'closed', 'label': '장외 시간', 'active': False, 'time': time_str}
|
||||
|
||||
|
||||
# 지금 거래 시간인 phase (거래가능 점·활성 열·실시간 보정 대상).
|
||||
_PHASE_TRADING = ('regular', 'nxt', 'after')
|
||||
|
||||
# phase 경계 시각(분) — _market_phase_state 의 분기 경계와 같은 값.
|
||||
_PHASE_BOUNDARIES_MIN = (8 * 60, 9 * 60, 15 * 60 + 30, 20 * 60)
|
||||
_PHASE_BOUNDARIES_MIN = (8 * 60, 9 * 60, 15 * 60 + 30, 16 * 60, 20 * 60)
|
||||
|
||||
|
||||
def _secs_to_next_phase() -> int | None:
|
||||
@@ -863,30 +878,41 @@ def _load_interests() -> list[dict]:
|
||||
|
||||
|
||||
_NXT_ENABLE_TTL = 300.0
|
||||
_nxt_enable_cache: dict = {'ts': 0.0, 'map': {}}
|
||||
_nxt_enable_cache: dict = {'ts': 0.0, 'map': {}, 'etf': {}}
|
||||
|
||||
|
||||
def _nxt_enable_map() -> dict:
|
||||
"""종목코드 → NXT 거래가능 여부(bool). `state/stock_codes.json` 캐시 읽기라 키움 콜 0.
|
||||
def _code_flag_maps() -> tuple[dict, dict]:
|
||||
"""종목코드 → (NXT 거래가능, ETF/ETN) 두 맵. `state/stock_codes.json` 읽기라 키움 콜 0.
|
||||
|
||||
⚠️ `kc.lookup_stock_meta` 는 호출마다 954KB JSON 을 다시 읽고 3500종목을 순회하므로
|
||||
100종목에 부르면 렌더가 멈춘다 — 코드 키 역인덱스를 만들어 5분 캐시한다.
|
||||
⚠️ 캐시에 없는 종목(신규 상장 등)은 여기서 빠지고, 호출측이 보수적으로 True 취급한다
|
||||
(조회를 한 번 더 하는 쪽이 값이 틀리는 것보다 낫다).
|
||||
⚠️ 두 플래그를 한 번에 만드는 이유는 같은 파일을 두 번 읽지 않기 위함이다.
|
||||
⚠️ 캐시에 없는 종목(신규 상장 등)은 여기서 빠지고, 호출측이 보수적 기본값을 쓴다.
|
||||
"""
|
||||
now = time.time()
|
||||
if now - _nxt_enable_cache['ts'] <= _NXT_ENABLE_TTL and _nxt_enable_cache['map']:
|
||||
return _nxt_enable_cache['map']
|
||||
return _nxt_enable_cache['map'], _nxt_enable_cache['etf']
|
||||
m: dict = {}
|
||||
e: dict = {}
|
||||
try:
|
||||
import kiwoom_client as kc
|
||||
m = {info['code']: bool(info.get('nxt_enable'))
|
||||
for info in kc._load_code_cache().values() if info.get('code')}
|
||||
for info in kc._load_code_cache().values():
|
||||
c = info.get('code')
|
||||
if not c:
|
||||
continue
|
||||
m[c] = bool(info.get('nxt_enable'))
|
||||
e[c] = bool(info.get('etf_etn'))
|
||||
except Exception:
|
||||
m = {}
|
||||
m, e = {}, {}
|
||||
if m:
|
||||
_nxt_enable_cache['map'] = m
|
||||
_nxt_enable_cache['etf'] = e
|
||||
_nxt_enable_cache['ts'] = now
|
||||
return m
|
||||
return m, e
|
||||
|
||||
|
||||
def _nxt_enable_map() -> dict:
|
||||
return _code_flag_maps()[0]
|
||||
|
||||
|
||||
def _is_nxt_traded(code: str) -> bool:
|
||||
@@ -896,6 +922,37 @@ def _is_nxt_traded(code: str) -> bool:
|
||||
return _nxt_enable_map().get(code, True)
|
||||
|
||||
|
||||
def _is_etf_etn(code: str) -> bool:
|
||||
"""ETF/ETN 인가. **KRX 애프터마켓(16:00~20:00)에서 거래되지 않는 종목군**이다.
|
||||
|
||||
2026-09-21 실측: TIGER 200 은 15:35 이후 거래량·현재가·호가잔량이 완전히 고정된다.
|
||||
⚠️ 모르면 **False(거래 가능)** 로 둔다 — 여기서 True 로 기울면 캐시에 없는 신규 종목을
|
||||
통째로 잠근다. 애프터마켓 제외종목은 ETF/ETN 말고도 9종류(거래정지·투자경고·이상급등 등)가
|
||||
더 있는데 그것들은 판별 필드가 없어 어차피 완전 차단이 불가능하다. 우리가 막을 수 있는
|
||||
것만 막고, 나머지는 키움 거부 메시지로 드러나게 한다.
|
||||
"""
|
||||
if not code:
|
||||
return False
|
||||
return _code_flag_maps()[1].get(code, False)
|
||||
|
||||
|
||||
def _phase_tradable(phase: str, code: str) -> bool:
|
||||
"""지금 이 종목이 거래 가능한가 — 거래가능 초록점·화면 표시 공용 판정.
|
||||
|
||||
정규장은 전부, NXT 시간대는 NXT 상장분만, 애프터마켓은 ETF/ETN 을 뺀 전부다.
|
||||
⚠️ 판정을 한 곳에 모은 이유 — 예전엔 미리보기 행과 보유행에 같은 if/elif 가 복제돼
|
||||
있었고, 2026-09-14 애프터마켓 신설처럼 조건이 하나 늘 때 **한쪽만 고치면 같은
|
||||
종목이 화면 두 곳에서 다르게 보인다**.
|
||||
"""
|
||||
if phase == 'regular':
|
||||
return True
|
||||
if phase == 'nxt':
|
||||
return _is_nxt_traded(code)
|
||||
if phase == 'after':
|
||||
return not _is_etf_etn(code)
|
||||
return False
|
||||
|
||||
|
||||
def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
|
||||
"""ka10095(관심종목정보) 한 콜로 워치리스트 시세 일괄 조회. 19종목 ~0.05s.
|
||||
캐시 히트인 entry는 캐시 사용, 미스인 entry만 batch에 포함.
|
||||
@@ -1469,7 +1526,7 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'AL positions price fetch failed: {e}\n')
|
||||
_phase = _market_phase_state().get('phase')
|
||||
active_map = ohlc_map_al if _phase in ('regular', 'nxt') else None
|
||||
active_map = ohlc_map_al if _phase in _PHASE_TRADING else None
|
||||
if active_map:
|
||||
for _items in positions_by_acc.values():
|
||||
for _p in _items:
|
||||
@@ -2949,15 +3006,11 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
|
||||
# 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음.
|
||||
# 정규장: KRX+NXT 모두 거래 → 초록점
|
||||
# NXT 시간대: NXT 거래 종목(종목 목록의 nxt_enable)만 초록점
|
||||
# 애프터마켓: ETF/ETN 빼고 전부 초록점 (KRX 애프터마켓이 일반주를 받는다)
|
||||
# 장마감·휴장·주말: 점 없음
|
||||
nxt_p = 1 if _is_nxt_traded(c.get('code') or '') else 0
|
||||
_phase = _market_phase_state()['phase']
|
||||
if _phase == 'regular':
|
||||
_tradable = True
|
||||
elif _phase == 'nxt':
|
||||
_tradable = nxt_p > 0
|
||||
else:
|
||||
_tradable = False
|
||||
_tradable = _phase_tradable(_phase, c.get('code') or '')
|
||||
mark_html = '<span class="px-dot tradable" title="지금 거래 가능"></span>' if _tradable else ''
|
||||
# 미리보기 등락은 KRX 전일종가 대비 — ka10095의 change_pct가 이미 KRX 기준이라 그대로 사용.
|
||||
day_pct = c.get('day_change_pct')
|
||||
@@ -4438,15 +4491,10 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
# 활성 열 판정 — OHLC 미니테이블 강조용. 두 열은 'KRX 단독' vs '하루 전체 통합'이라
|
||||
# 장중이면 항상 통합 열이 최신(현재가와 같은 값)이고, KRX 열은 15:30 이후 마감값으로 고정된다.
|
||||
_phase = _market_phase_state()['phase']
|
||||
mark = 'AL' if _phase in ('regular', 'nxt') else 'NONE'
|
||||
mark = 'AL' if _phase in _PHASE_TRADING else 'NONE'
|
||||
# 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음. (미리보기 행과 동일 규칙)
|
||||
# 정규장: KRX+NXT 모두 거래 → 초록점 / NXT 시간대: NXT 거래 종목(종목 목록의 nxt_enable)만 / 장외·휴장·주말: 점 없음
|
||||
if _phase == 'regular':
|
||||
_tradable = True
|
||||
elif _phase == 'nxt':
|
||||
_tradable = _is_nxt_traded(r.get('code') or '')
|
||||
else:
|
||||
_tradable = False
|
||||
# 정규장: 전부 / NXT 시간대: nxt_enable 만 / 애프터마켓: ETF/ETN 빼고 전부 / 장외·휴장·주말: 없음
|
||||
_tradable = _phase_tradable(_phase, r.get('code') or '')
|
||||
mark_html = '<span class="px-dot tradable" title="지금 거래 가능"></span>' if _tradable else ''
|
||||
|
||||
# OHLC mini-table — ka10095 KRX/AL batch 결과를 각 열에 표시. 활성 열만 밝게.
|
||||
@@ -4491,7 +4539,11 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
ohlc_al_for_render,
|
||||
active_market=mark.lower(),
|
||||
al_price=price, # 현재가 행은 통합 현재가를 양 열 공통 — 등락률만 각 기준 전일종가 대비.
|
||||
krx_closed=_trd_ohlc and _hm_ohlc >= 15 * 60 + 30,
|
||||
# ⚠️ KRX 열은 15:30~16:00 과 20:00 이후에만 '종가'다 — 애프터마켓(16:00~20:00)엔
|
||||
# KRX 가 접속매매로 다시 돌아 가격이 변한다(2026-09-21 실측). 예전처럼 15:30 이후
|
||||
# 전부 종가로 두면 "종가"라 써놓고 값이 계속 바뀐다. 15:40~16:00 시간외 종가매매는
|
||||
# 15:30 종가로만 체결돼 거래량만 늘므로 종가 표시가 맞다.
|
||||
krx_closed=_trd_ohlc and (15 * 60 + 30 <= _hm_ohlc < 16 * 60 or _hm_ohlc >= 20 * 60),
|
||||
al_closed=_trd_ohlc and _hm_ohlc >= 20 * 60,
|
||||
)
|
||||
|
||||
@@ -13055,6 +13107,13 @@ function updateMarketPhaseDisplay(){
|
||||
cls = 'closed';
|
||||
canTrade = false;
|
||||
}
|
||||
// 애프터마켓(16:00~20:00) — KRX 애프터마켓이 일반주를 받으므로 nxt_enable 은 보지 않는다.
|
||||
// ETF/ETN 만 거래불가 (2026-09-21 실측 확증).
|
||||
else if(phase.phase === 'after' && book && book.etf_etn === true){
|
||||
label = '📵 애프터마켓 거래불가 (ETF/ETN)';
|
||||
cls = 'closed';
|
||||
canTrade = false;
|
||||
}
|
||||
// 실시간 VI 발동 종목이면 모달 상태줄에 표시 (window.__rtState 는 realtime_script 가 채움)
|
||||
try {
|
||||
var _rt = window.__rtState;
|
||||
@@ -14934,6 +14993,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# NXT 시간대엔 KRX 호가창이 15:30 종가로 고정 → 현재가(NXT)와 갭 발생.
|
||||
# 그 시간대엔 NXT 호가창·NXT 현재가로 정합시킨다. 단 NXT 미상장 종목은 NXT 호가가
|
||||
# 없으므로 그 시간대에도 KRX 기준으로 표시.
|
||||
# ⚠️ 애프터마켓(16:00~20:00)은 KRX 를 쓴다 — KRX 애프터마켓 호가가 bare 조회로
|
||||
# 그대로 실려 온다(2026-09-21 실측: 삼성전자우 매도1/매수1 이 16:05·17:00·18:00
|
||||
# 모두 갱신). 이 구간을 'nxt' 로 묶어 NXT 호가를 보면 NXT 미상장 종목은
|
||||
# 호가가 통째로 비고, NXT 상장 종목도 KRX 쪽 물량을 못 본다.
|
||||
_ph = _market_phase_state().get('phase')
|
||||
use_nxt = (_ph == 'nxt') and nxt_enable
|
||||
if use_nxt:
|
||||
@@ -14958,6 +15021,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# 미리보기용으로 추가했다가 옵션 제거와 함께 제거(2026-07-30).
|
||||
# 기업정보 모달의 '52주 최고'는 /api/stock_info(get_stock_basic) 경로다.
|
||||
book['nxt_enable'] = nxt_enable # 위에서 판정한 값 재사용
|
||||
# 애프터마켓 거래불가 판정용 — ETF/ETN 은 16:00~20:00 에 거래되지 않는다.
|
||||
book['etf_etn'] = _is_etf_etn(code)
|
||||
# 종목이 속한 시장(KOSPI/KOSDAQ)의 20일 ADR 과매수/과매도 — 극단일 때만 실림.
|
||||
_mkt = (meta or {}).get('market') or ''
|
||||
book['market'] = _mkt
|
||||
|
||||
@@ -1213,6 +1213,12 @@ def refresh_stock_codes(account_label: str | None = None) -> dict:
|
||||
- nxt_enable: bool — ka10099 응답 nxtEnable=='Y' 변환. NXT 가드 정확화에 사용
|
||||
- state: str — 종목상태 (예: '관리종목', '정리매매', '증거금100%')
|
||||
- order_warning: str — 투자유의 (0/1/2/3/4/5, ka10099 명세 참조)
|
||||
- etf_etn: bool — ETF/ETN 여부 (2026-09-21 추가). **KRX 애프터마켓(16:00~20:00)에서
|
||||
ETF/ETN 은 거래되지 않는다**(2026-09-21 실측: TIGER 200 거래량·호가가 15:35 이후 완전
|
||||
고정). 자산웹이 애프터마켓에 이들만 잠그려면 판별 수단이 필요한데, KOSPI/KOSDAQ 조회
|
||||
응답에는 ETF 구분이 없다. 그래서 ETF/ETN 전용 시장을 따로 조회해 코드로 표시한다.
|
||||
⚠️ 종목명 프리픽스(KODEX/TIGER…) 추측을 쓰지 않는 이유 — 신규 운용사 브랜드가 생기면
|
||||
조용히 틀리고, 매매 게이트라 정확도가 우선이다.
|
||||
"""
|
||||
label = account_label or _default_account_label()
|
||||
url = base_url() + ENDPOINT_STKINFO
|
||||
@@ -1246,6 +1252,40 @@ def refresh_stock_codes(account_label: str | None = None) -> dict:
|
||||
time.sleep(0.15)
|
||||
else:
|
||||
break
|
||||
|
||||
# ETF/ETN 표시 — 전용 시장을 훑어 코드 집합을 만들고 위에서 담은 항목에 플래그만 얹는다.
|
||||
# ETF/ETN 은 KOSPI/KOSDAQ 조회에도 이미 들어 있으므로 **새 항목을 추가하지 않는다**.
|
||||
# ⚠️ 여기서 실패해도 캐시 저장은 계속한다 — 플래그 하나 때문에 4300종목 캐시를
|
||||
# 통째로 잃으면 종목명 검색·NXT 판정이 다 멈춘다. 플래그가 없으면 호출측이
|
||||
# 보수적 기본값으로 떨어질 뿐이다.
|
||||
# ⚠️ ka10099 유량은 초당 5건이고 위 페이지네이션이 이미 버스트를 쌓아놨다 — 0.15s 간격으로
|
||||
# 이어 붙이면 마지막 시장이 429 로 튕긴다(2026-09-21 실측: mrkt_tp=90 8종목 누락).
|
||||
# 빠진 시장의 ETF/ETN 은 플래그가 False 로 남아 애프터마켓에 열려버리므로 재시도한다.
|
||||
etf_codes: set[str] = set()
|
||||
for mrkt_tp in ('8', '60', '70', '90'): # 8:ETF, 60:ETN, 70:손실제한ETN, 90:변동성ETN
|
||||
for attempt in range(2):
|
||||
time.sleep(0.5 if attempt == 0 else 1.5)
|
||||
try:
|
||||
resp, _ = _http_post_full(url, {'mrkt_tp': mrkt_tp},
|
||||
auth_headers(label, tr_id=TR_STOCK_LIST))
|
||||
if resp.get('return_code', 0) != 0:
|
||||
if _is_quote_rate_limit(str(resp.get('return_msg') or '')):
|
||||
continue
|
||||
break
|
||||
for it in (resp.get('list') or []):
|
||||
c = _clean_code(it.get('code', ''))
|
||||
if c:
|
||||
etf_codes.add(c)
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt == 0 and _is_quote_rate_limit(str(e)):
|
||||
continue
|
||||
print(f'[refresh_stock_codes] ETF/ETN 표시 실패 (mrkt_tp={mrkt_tp}): {e}',
|
||||
file=sys.stderr)
|
||||
break
|
||||
for info in result.values():
|
||||
info['etf_etn'] = info['code'] in etf_codes
|
||||
|
||||
STOCK_CODES_CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||
STOCK_CODES_CACHE.write_text(json.dumps({
|
||||
'refreshed_at': datetime.now(KST).isoformat(),
|
||||
|
||||
@@ -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) # 고점 = 발동 시점 현재가
|
||||
|
||||
@@ -270,7 +270,8 @@ def _fire_arm(arm: dict, plan: dict, sess: str, dry_run: bool) -> None:
|
||||
# 라우팅은 등록 시점이 아니라 지금 정한다 — 같은 날 안에서도 정규장에 등록해 NXT
|
||||
# 애프터에 발동할 수 있고, 옛 suffix 로 발주하면 거부된다.
|
||||
try:
|
||||
suffix = guards.determine_routing(datetime.now(KST), datasource.nxt_eligible(symbol), None)
|
||||
suffix = guards.determine_routing(datetime.now(KST), datasource.nxt_eligible(symbol),
|
||||
None, 'STOP_LIMIT')
|
||||
except ValueError as e:
|
||||
_log(f'라우팅 결정 실패 {arm["id"]} (세션 {sess}): {e!r}')
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user