auto: 일일 백업 2026-09-22 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user