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
+93 -28
View File
@@ -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