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':