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