auto: 일일 백업 2026-09-11 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-09-11 02:00:03 +09:00
parent 92d0d96368
commit b4f510d1ad
188 changed files with 4782 additions and 3353 deletions
+261 -123
View File
@@ -862,6 +862,40 @@ def _load_interests() -> list[dict]:
return sorted(data.values(), key=lambda e: e.get('saved_at', ''), reverse=True)
_NXT_ENABLE_TTL = 300.0
_nxt_enable_cache: dict = {'ts': 0.0, 'map': {}}
def _nxt_enable_map() -> dict:
"""종목코드 → NXT 거래가능 여부(bool). `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']
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')}
except Exception:
m = {}
if m:
_nxt_enable_cache['map'] = m
_nxt_enable_cache['ts'] = now
return m
def _is_nxt_traded(code: str) -> bool:
"""NXT 에서 거래되는 종목인가. 모르면 True(보수적) — 아래 KRX 보정 조회를 한 번 더 한다."""
if not code:
return True
return _nxt_enable_map().get(code, True)
def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
"""ka10095(관심종목정보) 한 콜로 워치리스트 시세 일괄 조회. 19종목 ~0.05s.
캐시 히트인 entry는 캐시 사용, 미스인 entry만 batch에 포함.
@@ -901,9 +935,8 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
out['high'] = snap.get('high')
out['low'] = snap.get('low')
out['volume'] = snap.get('volume')
out['_nxt_volume'] = snap.get('_nxt_volume', 0)
out['_krx_volume'] = snap.get('_krx_volume', 0)
out['_krx_price'] = snap.get('_krx_price', 0)
out['_nxt_price'] = snap.get('_nxt_price', 0)
continue
if code:
miss_codes.append(code)
@@ -915,8 +948,10 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
if not miss_codes:
return cards
nxt_probe_codes = list(miss_codes)
nxt_probe_indices = list(miss_indices)
# KRX 보정 조회 대상 — NXT 에서도 거래되는 종목만. NXT 미거래 종목은 AL 이 곧 KRX 값이라
# 조회해봐야 같은 값이 온다(실측 101종목 예외 0건). 종목이 늘어도 이 규칙은 그대로 성립한다.
krx_probe_codes = [c for c in miss_codes if _is_nxt_traded(c)]
krx_probe_indices = [i for i, c in zip(miss_indices, miss_codes) if _is_nxt_traded(c)]
# WS 허브 연결 시 — 미스 종목 시세를 허브 메모리(0B 실시간, KRX+NXT 통합)에서 우선 충당.
# 서버가 동일 렌더러로 OHLC를 그린다. 단, NXT 거래가능 초록점은 허브 exchange가 아니라
@@ -943,7 +978,7 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
out['low'] = q.get('low') or None
out['volume'] = q.get('volume')
out['_krx_price'] = price
out['_nxt_price'] = 0
out['_krx_volume'] = q.get('volume') or 0
miss_codes, miss_indices = rest_codes, rest_indices
batch: dict[str, dict] = {}
@@ -955,20 +990,20 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
for idx in miss_indices:
cards[idx] = _fetch_quote(entries[idx])
# NXT 비교용 batch — 마크(KRX/NXT) 판정에 사용. 실패해도 fallback X (마크만 KRX로 처리).
# get_watchlist_quotes는 NXT 단독 suffix를 'NX'로 받는다. 'NXT'는 suffix 없이 KRX/기본값으로
# 떨어져 NXT 미거래 종목도 거래 가능처럼 보이므로 쓰면 안 된다.
nxt_batch: dict[str, dict] = {}
# KRX 단독 batch — RVOL 이 KRX 기준(일봉 ka10081 이 KRX 거래량이라 분모와 맞춰야 한다)이라
# 필요하다. 예전엔 AL−NXT 로 역산했는데 두 조회의 시점차만큼 오차가 났다(실측 삼성전자 14주).
# 실패해도 fallback X — 아래에서 AL 값으로 떨어진다.
krx_batch: dict[str, dict] = {}
try:
nxt_batch = kc.get_watchlist_quotes(nxt_probe_codes, exchange='NX')
krx_batch = kc.get_watchlist_quotes(krx_probe_codes, exchange='KRX')
except Exception as ex:
sys.stderr.write(f'ka10095 NXT batch 실패 (마크 판정용): {ex}\n')
sys.stderr.write(f'ka10095 KRX batch 실패 (RVOL 보정용): {ex}\n')
for idx, code in zip(nxt_probe_indices, nxt_probe_codes):
nxt_q = nxt_batch.get(code) if nxt_batch else None
cards[idx]['_nxt_price'] = (nxt_q or {}).get('price', 0) if nxt_q else 0
# NXT 거래량 — RVOL은 KRX 기준(일봉 평균과 동일)이라 KRX=AL−NXT 계산에 사용.
cards[idx]['_nxt_volume'] = (nxt_q or {}).get('volume', 0) if nxt_q else 0
for idx, code in zip(krx_probe_indices, krx_probe_codes):
krx_q = krx_batch.get(code) if krx_batch else None
if krx_q:
cards[idx]['_krx_price'] = krx_q.get('price', 0)
cards[idx]['_krx_volume'] = krx_q.get('volume', 0)
for idx, code in zip(miss_indices, miss_codes):
q = batch.get(code)
@@ -987,10 +1022,12 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
out['volume'] = q.get('volume')
else:
out['quote_error'] = '가격 0'
# 마크 판정: NXT batch 가격 vs 기본 batch 가격 비교.
nxt_price = out.get('_nxt_price', 0) or 0
out['_krx_price'] = price
out['_nxt_price'] = nxt_price
# ⚠️ KRX 값은 위 krx_batch 루프가 이미 채웠을 수 있다 — 덮어쓰지 말 것.
# 비어 있는 종목(NXT 미거래 또는 KRX 조회 실패)은 AL 이 곧 KRX 값이다.
if not out.get('_krx_price'):
out['_krx_price'] = price
if not out.get('_krx_volume'):
out['_krx_volume'] = out.get('volume') or 0
snap = {
'price': out['price'],
'day_change': out['day_change'],
@@ -1000,9 +1037,8 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
'high': out['high'],
'low': out['low'],
'volume': out.get('volume'),
'_nxt_volume': out.get('_nxt_volume', 0),
'_krx_price': price,
'_nxt_price': nxt_price,
'_krx_volume': out.get('_krx_volume', 0),
'_krx_price': out.get('_krx_price', 0),
}
with _quote_cache_lock:
_quote_cache[code] = (snap, time.time() + QUOTE_CACHE_TTL)
@@ -1209,6 +1245,14 @@ def _build_owner_data(positions_by_acc: dict, balances: dict, journal_by_label:
# 부풀려지는 문제를 해결한다. 합계 KPI는 동일 NXT 스냅샷의 prev_eval과 비교하므로
# 개별 합 ≈ 합계로 자연스럽게 일치한다.
prev_holdings = (prev_snap_all or {}).get(owner, {}).get('holdings') or {}
# ⚠️ rebase 는 '당일 등락' 계산용 baseline 을 만드는 함수라, 어제 보유가 없는 종목
# (= 오늘 신규 매수)엔 매수 평단을 넣는다. 그건 손익 관점에선 맞지만 전일종가가 아니다.
# 미니표 '전일종가' 칸이 그 값을 쓰면 평단이 전일종가로 표시되므로(실측 우리기술
# 평단 13,780 이 전일종가 자리에 노출), 진짜 어제 종가만 따로 보존해 둔다.
for _r in consolidated:
_pp = (prev_holdings.get(_r.get('stock')) or {}).get('price', 0) or 0
if _pp > 0:
_r['pred_close_snap'] = _pp
rebase_to_nxt_close(consolidated, prev_holdings)
total_cost = sum(r['buy_amount'] for r in consolidated)
@@ -1399,39 +1443,33 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
except Exception as e:
sys.stderr.write(f'positions fetch failed: {e}\n')
positions_by_acc = {}
# 가격 보정 — KRX와 NXT 각각 ka10095 batch 호출.
# kiwoom_client.get_watchlist_quotes 의 exchange: 'KRX'=KRX 단독 / 'NX'=NXT 단독 / 'AL'=통합.
# ('NXT' 값은 함수에서 인식 안 되고 KRX 기본으로 떨어짐 — 사용 X)
# phase별 우선 시장:
# - regular(09:00~15:30): KRX 라이브 가격
# - nxt(08:00~09:00, 15:30~20:00): NXT 라이브 가격
# - 그 외(closed/holiday/weekend): kt00018 NXT 응답 그대로 유지
# 가격 보정 — KRX·AL(통합) 2콜. exchange: 'KRX'=KRX 단독 / 'AL'=KRX+NXT 통합.
# ⚠️ NXT 단독('NX') 조회는 2026-09-10 제거 — 통합(AL)이 NXT 시간대엔 NXT 최신 체결과
# 같은 값을 주고(실측 가격 4값 일치), NXT 거래 여부는 종목 목록(nxt_enable)으로 안다.
# 현재가 보정은 시간대와 무관하게 AL — AL 이 곧 '지금 활성 시장의 최신 체결'이라
# 예전의 phase별 분기(regular=KRX / nxt=NXT)가 필요 없다.
# 장외(closed/holiday/weekend)엔 보정하지 않고 kt00018 응답을 그대로 둔다.
held_codes_set: set[str] = set()
for _items in positions_by_acc.values():
for _p in _items:
if _p.get('code'):
held_codes_set.add(_p['code'])
ohlc_map_krx: dict[str, dict] = {}
ohlc_map_nxt: dict[str, dict] = {}
ohlc_map_al: dict[str, dict] = {}
# KRX/NX 2콜은 가격 보정뿐 아니라 보유종목 행의 KRX·NXT 듀얼 OHLC 미니(r['ohlc_krx']·['ohlc_nxt'])
# 데이터원이다. _AL 허브는 거래소별 단일 최신값만 들어 두 컬럼을 동시에 못 주므로 skip 불가 — WS 연결과 무관하게 호출.
# AL(통합) 1콜은 요약 행 단일 캔들(r['ohlc']) 전용 — 시가는 NXT 프리마켓 시가, 고저는 두 시장 극값.
# KRX 1콜 = 보유행 듀얼 OHLC 미니의 KRX 열(r['ohlc_krx']), AL 1콜 = 통합 열(r['ohlc_al'])
# + 요약 행 단일 캔들(r['ohlc']) + 현재가 보정. 허브(_AL 구독)는 거래소별 단일 최신값만
# 들어 두 열을 동시에 못 주므로 skip 불가 — WS 연결과 무관하게 호출.
if held_codes_set:
try:
ohlc_map_krx = kc.get_watchlist_quotes(list(held_codes_set), exchange='KRX')
except Exception as e:
sys.stderr.write(f'KRX positions price fetch failed: {e}\n')
try:
ohlc_map_nxt = kc.get_watchlist_quotes(list(held_codes_set), exchange='NX')
except Exception as e:
sys.stderr.write(f'NXT positions price fetch failed: {e}\n')
try:
ohlc_map_al = kc.get_watchlist_quotes(list(held_codes_set), exchange='AL')
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_nxt if _phase == 'nxt' else (ohlc_map_krx if _phase == 'regular' else None)
active_map = ohlc_map_al if _phase in ('regular', 'nxt') else None
if active_map:
for _items in positions_by_acc.values():
for _p in _items:
@@ -1563,6 +1601,9 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
bucket['orders'].append(ord_entry)
g['orders'].append(ord_entry)
# 대기 중 예약 트레일링 — 키움 미체결에 안 잡히므로 상태파일에서 따로 읽어 같이 주입한다.
arms_by_code = _arms_by_code()
holdings_map = _holdings_map_from_positions(positions_by_acc)
for c in cards:
_annotate_card(c, holdings_map)
@@ -1572,6 +1613,9 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
c['pending_buy_qty'] = p['buy_qty']
c['pending_sell_qty'] = p['sell_qty']
c['pending_orders'] = p['orders']
a = arms_by_code.get(c.get('code') or '')
if a:
c['arm_pending'] = a
prev_key = None
prev_snap_all: dict = {}
@@ -1629,7 +1673,11 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
balances.get(lb, {}).get('pending_buy_locked', 0) for lb in od.get('labels', [])
)
# consolidated/phantom 행에 미체결 수량 주입 (owner 단위)
# consolidated/phantom 행에 미체결 수량 + 대기 예약 주입 (owner 단위)
def _owner_arms(owner_name: str, code: str) -> list:
return [a for a in arms_by_code.get(code or '', [])
if derive_owner(a.get('account', '')) == owner_name]
for owner_name, od in owner_data.items():
owner_pending = pending_by_owner.get(owner_name, {})
for r in od.get('consolidated', []):
@@ -1638,12 +1686,18 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
r['pending_buy_qty'] = p['buy_qty']
r['pending_sell_qty'] = p['sell_qty']
r['pending_orders'] = p['orders']
a = _owner_arms(owner_name, r.get('code') or '')
if a:
r['arm_pending'] = a
for r in od.get('phantom', []) if isinstance(od.get('phantom'), list) else []:
p = owner_pending.get(r.get('code') or '')
if p:
r['pending_buy_qty'] = p['buy_qty']
r['pending_sell_qty'] = p['sell_qty']
r['pending_orders'] = p['orders']
a = _owner_arms(owner_name, r.get('code') or '')
if a:
r['arm_pending'] = a
# 미체결 매수 주문 중 owner 미보유 종목 → owner 패널 별도 섹션 노출.
# consolidated에 들어가지 않은 코드만 추림 (보유 + 당일정산 모두 제외).
@@ -1683,43 +1737,35 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
r['open'] = q.get('open') # 미리보기 등락은 시가 대비 → open 필요
# 보유 종목 OHLC batch — 카드 펼치면 보이는 오늘 단일 캔들 + 자세히 보기 mini-table용.
# ohlc_map_krx (exchange='KRX')와 ohlc_map_nxt (exchange='NX'=NXT 단독)는 positions 보정에서 이미 호출 — 재사용.
# 각 컬럼은 그 시장 데이터로 정확히 채움 (서로 섞이지 않도록). primary 단일 캔들은 통합(AL) 기준.
if ohlc_map_krx or ohlc_map_nxt or ohlc_map_al:
_phase = _market_phase_state().get('phase')
# ohlc_map_krx(KRX 단독)·ohlc_map_al(통합)은 positions 보정에서 이미 호출 — 재사용.
# 미니표 두 열은 'KRX 단독' vs '하루 전체 통합'이다. primary 단일 캔들도 통합(AL) 기준.
if ohlc_map_krx or ohlc_map_al:
for od in owner_data.values():
for r in od.get('consolidated', []):
code = r.get('code')
qk = ohlc_map_krx.get(code) if code else None
qn = ohlc_map_nxt.get(code) if code else None
qa = ohlc_map_al.get(code) if code else None
# price 만 있어도 valid — 거래 없는 시간대(시가/고가/저가=0)도 종가만 표시되도록.
qk_valid = bool(qk and qk.get('price'))
qn_valid = bool(qn and qn.get('price'))
qa_valid = bool(qa and qa.get('price'))
if qk_valid:
r['ohlc_krx'] = {
'open': qk['open'], 'high': qk['high'], 'low': qk['low'],
'price': qk.get('price', 0), 'change_pct': qk.get('change_pct', 0.0),
'change': qk.get('change', 0), 'volume': qk.get('volume', 0),
}
if qn_valid:
r['ohlc_nxt'] = {
'open': qn['open'], 'high': qn['high'], 'low': qn['low'],
'price': qn.get('price', 0), 'change_pct': qn.get('change_pct', 0.0),
'change': qn.get('change', 0), 'volume': qn.get('volume', 0),
if qa_valid:
r['ohlc_al'] = {
'open': qa['open'], 'high': qa['high'], 'low': qa['low'],
'price': qa.get('price', 0), 'change_pct': qa.get('change_pct', 0.0),
'change': qa.get('change', 0), 'volume': qa.get('volume', 0),
}
# 단일 캔들 primary — 4값(open/high/low/price) 모두 있는 응답만 후보. 거래 없는 시간대 0값 응답 배제.
# AL(통합) 우선 — 시간대에 따라 KRX↔NXT 로 캔들이 뒤바뀌지 않고 하루 한 개로 유지된다.
# NXT 미거래 종목은 AL == KRX 라 결과가 같다. AL 응답이 불완전할 때만 phase 우선순위로 폴백.
qa = ohlc_map_al.get(code) if code else None
# 통합(AL) 우선 — 시간대에 따라 캔들이 뒤바뀌지 않고 하루 한 개로 유지된다.
# NXT 미거래 종목은 AL == KRX 라 결과가 같다. AL 이 불완전할 때만 KRX 로 폴백.
qk_full = qk_valid and qk.get('open') and qk.get('high') and qk.get('low')
qn_full = qn_valid and qn.get('open') and qn.get('high') and qn.get('low')
qa_full = bool(qa and qa.get('price') and qa.get('open') and qa.get('high') and qa.get('low'))
if qa_full:
primary = qa
elif _phase == 'regular':
primary = qk if qk_full else (qn if qn_full else None)
else:
primary = qn if qn_full else (qk if qk_full else None)
qa_full = qa_valid and qa.get('open') and qa.get('high') and qa.get('low')
primary = qa if qa_full else (qk if qk_full else None)
if primary:
r['ohlc'] = {'o': primary['open'], 'h': primary['high'], 'l': primary['low'], 'c': primary.get('price', 0) or r.get('price', 0)}
@@ -2902,9 +2948,9 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
else:
# 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음.
# 정규장: KRX+NXT 모두 거래 → 초록점
# NXT 시간대: NXT 거래되는 종목(_nxt_price 살아있음)만 초록점
# NXT 시간대: NXT 거래 종목(종목 목록의 nxt_enable)만 초록점
# 장마감·휴장·주말: 점 없음
nxt_p = c.get('_nxt_price', 0) or 0
nxt_p = 1 if _is_nxt_traded(c.get('code') or '') else 0
_phase = _market_phase_state()['phase']
if _phase == 'regular':
_tradable = True
@@ -3141,7 +3187,7 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
return f'''<details class="row {cls} mode-{mode}" data-row-key="{row_key}">
<summary>
<div class="left">
<div class="line1">{dot_html}<span class="stock">{stock}</span>{tag_chip}{status_cell}{trade_mark}{alert_badge}{_vol_surge_badge(code, (max(0, (c.get('volume') or 0) - (c.get('_nxt_volume') or 0)) or None))}</div>
<div class="line1">{dot_html}<span class="stock">{stock}</span>{tag_chip}{status_cell}{trade_mark}{alert_badge}{_vol_surge_badge(code, (c.get('_krx_volume') or c.get('volume') or None))}</div>
</div>
<div class="right">
<div class="price">{price_cell}<span class="caret" aria-hidden="true">▾</span></div>
@@ -3755,28 +3801,22 @@ def _render_account_kpi(owner: str, label: str, owner_d: dict, balances: dict, j
</section>'''
def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: str = 'none', al_price: int = 0, krx_closed: bool = False, nxt_closed: bool = False) -> str:
"""보유종목 자세히 보기용 OHLC mini-table — KRX/NXT 두 거래소 가격·등락률 분리 표시.
def _render_ohlc_mini_dual(krx: dict | None, al: dict | None, active_market: str = 'none', al_price: int = 0, krx_closed: bool = False, al_closed: bool = False) -> str:
"""보유종목 자세히 보기용 OHLC mini-table — 'KRX 단독' / '통합(KRX+NXT)' 2열.
각 dict: {open, high, low, price, change_pct} (price=종가/현재가, change_pct=직전 영업일 종가 대비).
한쪽이 None이면 해당 컬럼 전체 '-' placeholder. 4행(현재가/시가/고가/저가) × 2컬럼(KRX/NXT).
한쪽이 None이면 그 열 전체 '-' placeholder. 7행(전일종가/현재가/시가/고가/저가/당일종가/거래량).
active_market: 'krx' / 'nxt' / 'none'. 활성 시장 컬럼은 기본 색, 비활성은 회색 처리.
active_market: 'krx' / 'al' / 'none'. 활성 열은 기본 색, 비활성은 회색 처리.
⚠️ 2026-09-10 이전엔 두 번째 열이 NXT 단독이었다. NXT 조회를 없애면서 통합으로 바꿨다 —
통합 시가는 NXT 프리마켓 첫 체결이라 KRX 시가와 다른 값이고, 그대로 보여준다.
"""
# price 만 있어도 컬럼 표시 — 거래 없는 시간대도 종가 노출. open/high/low 0 셀은 _cell 안에서 '-' 처리.
# price 만 있어도 열 표시 — 거래 없는 시간대도 종가 노출. open/high/low 0 셀은 _cell 안에서 '-' 처리.
has_krx = bool(krx and krx.get('price'))
has_nxt = bool(nxt and nxt.get('price'))
if not (has_krx or has_nxt):
has_al = bool(al and al.get('price'))
if not (has_krx or has_al):
return ''
# NXT 시가 baseline 은 호출처에서 어제 NXT 스냅샷 종가(r['pred_close'])로 미리 박아 전달.
# 자동 baseline 계산(price - change)은 호출처가 못 박은 경우만 fallback.
if has_nxt and nxt and not nxt.get('open'):
nxt_baseline = (nxt.get('price', 0) or 0) - (nxt.get('change', 0) or 0)
if nxt_baseline > 0:
nxt = dict(nxt)
nxt['open'] = nxt_baseline
def _cell(d: dict | None, key: str, dim: bool, closed: bool = False) -> str:
dim_cls = ' ohlc-dim' if dim else ''
if not d:
@@ -3823,18 +3863,9 @@ def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: st
f'<span class="ohlc-pct {cls}">{sgn}{pct:.2f}%</span></span>'
)
krx_dim = active_market not in ('krx', 'none-active-krx') # 'krx' 활성 또는 양쪽 비활성-krx강조 시만 밝게
nxt_dim = active_market != 'nxt'
# 'none' (양쪽 비활성, 휴장/심야) — 둘 다 dim
if active_market == 'none':
krx_dim = True
nxt_dim = True
elif active_market == 'krx':
krx_dim = False
nxt_dim = True
elif active_market == 'nxt':
krx_dim = True
nxt_dim = False
# 'none' (장외·휴장) — 둘 다 dim / 'al' (장중) — 통합 열만 밝게
krx_dim = active_market != 'krx'
al_dim = active_market != 'al'
rows = [
('전일종가', 'prev_close'),
@@ -3846,17 +3877,17 @@ def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: st
('거래량', 'volume'),
]
krx_th_dim = ' ohlc-dim' if krx_dim else ''
nxt_th_dim = ' ohlc-dim' if nxt_dim else ''
al_th_dim = ' ohlc-dim' if al_dim else ''
parts = [
'<div class="ohlc-mini ohlc-dual">',
'<span class="ohlc-th"></span>',
f'<span class="ohlc-th{krx_th_dim}">KRX</span>',
f'<span class="ohlc-th{nxt_th_dim}">NXT</span>',
f'<span class="ohlc-th{al_th_dim}">통합</span>',
]
for lbl, key in rows:
parts.append(f'<span class="ohlc-rl">{lbl}</span>')
parts.append(_cell(krx if has_krx else None, key, krx_dim, krx_closed))
parts.append(_cell(nxt if has_nxt else None, key, nxt_dim, nxt_closed))
parts.append(_cell(al if has_al else None, key, al_dim, al_closed))
parts.append('</div>')
return ''.join(parts)
@@ -3944,6 +3975,7 @@ def _interleave_kv_pairs(left: list[str], right: list[str]) -> str:
def _pending_badges_html(r: dict) -> str:
"""미리보기 💰 배지 — 색만으로 매수(빨강)/매도(파랑) 구분.
대기 중 예약 트레일링은 키움 주문이 아니라서 ⏳ 로 따로 표시한다.
수량·상세 정보는 자세히보기(`_pending_detail_html`)에서.
"""
buy = r.get('pending_buy_qty', 0)
@@ -3957,6 +3989,12 @@ def _pending_badges_html(r: dict) -> str:
parts.append(
f'<span class="badge pending-sell" title="키움 미체결 매도 {sell:,}주 — 자세히보기에서 확인">💰</span>'
)
arms = r.get('arm_pending') or []
if arms:
aq = sum(a.get('qty', 0) for a in arms)
parts.append(
f'<span class="badge pending-arm" title="예약 트레일링 대기 {aq:,}주 — 자세히보기에서 확인">⏳</span>'
)
return ''.join(parts)
@@ -4013,6 +4051,35 @@ def _arm_rows() -> list:
return out
def _arms_by_code() -> dict:
"""종목코드 → 대기 중 예약 트레일링 요약 리스트. 상태파일 읽기라 키움 콜 0.
⚠️ 예약 트레일링은 키움에 주문이 없어 ka10075 미체결에 안 잡힌다 — 종목 행 배지·상세의
유일한 근거가 이 맵이다(미체결 기반 `pending_*` 만 보면 화면이 통째로 조용해진다).
owner 필터는 주입 측이 `account` 로 한다.
"""
out: dict[str, list] = {}
try:
from orders import trailing as _trl
arms = _trl.list_arms()
except Exception:
return out
for a in arms:
try:
expired = _trl.is_arm_expired(a)
except Exception:
expired = False
out.setdefault(a.get('symbol') or '', []).append({
'id': a.get('id', ''), 'account': a.get('account', ''), 'qty': a.get('qty', 0),
'trigger_price': a.get('trigger_price', 0),
'min_sell_price': a.get('min_sell_price'),
'steps': a.get('steps') or [],
'firing_at': a.get('firing_at'), 'created_at': a.get('created_at'),
'expired': expired,
})
return out
def _trailing_info_for(ord_no: str):
"""미체결 주문번호 → 트레일링 예약 요약 (없으면 None).
@@ -4084,7 +4151,8 @@ def _pending_detail_html(r: dict) -> str:
레이아웃: dt(라벨, 컬러) / dd 2~3줄(상단=수량@가격, 조건줄, 하단=계좌·시각·주문번호 muted).
"""
orders = r.get('pending_orders') or []
if not orders:
arms = r.get('arm_pending') or []
if not orders and not arms:
return ''
buys = [o for o in orders if o.get('side') == 'BUY']
sells = [o for o in orders if o.get('side') == 'SELL']
@@ -4187,11 +4255,69 @@ def _pending_detail_html(r: dict) -> str:
f'</dd>'
)
def _arm_row(a: dict) -> str:
"""대기 중 예약 트레일링 — 키움 주문이 없어 주문번호·지정가가 없다.
미체결 행과 같은 골격(주요 줄 / 조건 줄 / meta)으로 맞춰 나란히 읽히게 한다."""
qty = a.get('qty', 0)
trig = a.get('trigger_price', 0) or 0
steps = a.get('steps') or []
if a.get('firing_at'):
label = '예약 발동 중단'
elif a.get('expired'):
label = '예약 만료'
else:
label = '예약 트레일링'
if a.get('firing_at'):
# 발동 도중 중단된 상태 — 주문이 나갔는지 모르므로 자동 재발동하지 않는다.
cond_line = ('<div class="small pending-line-cond">발동 처리 중단 — 미체결 확인 후 '
'진행중 팝업에서 수동 취소</div>')
elif a.get('expired'):
# 감시는 08:00~19:59 에만 돌아 20:00 직후엔 파일에 남아 있다. 숨기면 화면이 거짓말.
cond_line = ('<div class="small pending-line-cond">만료 — 다음 거래일 첫 감시 사이클에 정리'
'</div>')
else:
cond_line = (f'<div class="small pending-line-cond trailing">'
f'{trig:,}원 닿으면 트레일링 시작</div>')
mini = [f'<dt>발동가</dt><dd><b>{trig:,}원</b> 도달 시</dd>']
if cur_price > 0 and trig > 0:
# 발동가까지 남은 거리 — 미체결 트레일링의 '손절선 대비'와 같은 자리·같은 읽는 법.
mini.append(f'<dt>발동가까지</dt><dd>{(trig - cur_price) / cur_price * 100:+.1f}% '
f'<span class="muted">(현재 {cur_price:,}원)</span></dd>')
if steps:
desc = ' · '.join(
f'−{x.get("pct"):g}%→'
+ ('전량' if (x.get('cum') or 0) >= 100 else f'{x.get("cum") or 0:g}%')
for x in steps)
mini.append(f'<dt>계단</dt><dd><b>{len(steps)}단</b> · {desc}</dd>')
if a.get('min_sell_price'):
mini.append(f'<dt>최저 매도가</dt><dd>{a["min_sell_price"]:,}원</dd>')
cond_line += f'<dl class="trail-mini">{"".join(mini)}</dl>'
meta_parts = [html.escape(a.get('account', ''))]
ct = (a.get('created_at') or '')[5:16].replace('T', ' ')
if ct:
meta_parts.append(ct)
if a.get('id'):
meta_parts.append(html.escape(a['id']))
return (
f'<dt class="pending-arm-label">{label}</dt>'
f'<dd>'
f'<div class="num pending-line-main">{qty:,}주 매도 예약</div>'
f'{cond_line}'
f'<div class="muted small pending-line-meta">{" · ".join(meta_parts)}</div>'
f'</dd>'
)
pairs: list[str] = []
for o in buys:
pairs.append(_row(o, 'buy'))
for o in sells:
pairs.append(_row(o, 'sell'))
for a in arms:
pairs.append(_arm_row(a))
return f'<dl class="pending-detail">{"".join(pairs)}</dl>'
@@ -4306,27 +4432,24 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
f'<dt>당일 등락</dt><dd class="num"><span class="{dcls} rt-daychange">{dsign}{day_change:,}원 '
f'({dsign}{day_change_pct:.2f}%)</span></dd>'
)
# 활성 시장 판정 — OHLC 미니테이블 컬럼 강조용(active_market). cur_price 가 어느 시장 가격과 일치하는지.
nxt_p = (r.get('ohlc_nxt') or {}).get('price', 0)
krx_p = (r.get('ohlc_krx') or {}).get('price', 0)
mark = 'NXT' if (nxt_p and nxt_p == price and nxt_p != krx_p) else 'KRX'
# 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음. (미리보기 행과 동일 규칙)
# 정규장: KRX+NXT 모두 거래 → 초록점 / NXT 시간대: NXT 거래종목(ohlc_nxt 가격 살아있음)만 / 장외·휴장·주말: 점 없음
# 활성 열 판정 — OHLC 미니테이블 강조용. 두 열은 'KRX 단독' vs '하루 전체 통합'이라
# 장중이면 항상 통합 열이 최신(현재가와 같은 값)이고, KRX 열은 15:30 이후 마감값으로 고정된다.
_phase = _market_phase_state()['phase']
mark = 'AL' if _phase in ('regular', 'nxt') else 'NONE'
# 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음. (미리보기 행과 동일 규칙)
# 정규장: KRX+NXT 모두 거래 → 초록점 / NXT 시간대: NXT 거래 종목(종목 목록의 nxt_enable)만 / 장외·휴장·주말: 점 없음
if _phase == 'regular':
_tradable = True
elif _phase == 'nxt':
_tradable = bool(nxt_p)
_tradable = _is_nxt_traded(r.get('code') or '')
else:
_tradable = False
mark_html = '<span class="px-dot tradable" title="지금 거래 가능"></span>' if _tradable else ''
# OHLC mini-table — ka10095 NX/NXT batch 결과를 각 컬럼에 분리 표시. 활성 시장만 밝게.
# _nxt_inactive 마커는 stock_portfolio_report의 NX 응답 기반 판정이라 ETF처럼 NXT 정상거래 종목도
# 잘못 잡힘 → 여기선 무시하고 실제 ohlc_nxt 데이터 유무로 표시 결정.
# NXT 컬럼 baseline 을 어제 NXT 스냅샷 종가(r['pred_close']) 로 통일.
# change/change_pct 도 스냅샷 baseline 기준으로 재계산 → cell 안 pct 표시도 일관.
# 전 거래일 종가 행: KRX = pred_close_krx (KRX 전일종가), NXT = r['pred_close'] (어제 NXT 스냅샷).
# OHLC mini-table — ka10095 KRX/AL batch 결과를 각 열에 표시. 활성 열만 밝게.
# 두 열의 전일종가 기준이 다르다: KRX 열 = KRX 전일종가, 통합 열 = 어제 NXT 스냅샷 종가
# (r['pred_close']). 통합 열 baseline 을 스냅샷으로 두는 이유 — 보유행 day_change 가 같은
# 값을 baseline 으로 쓰므로, 여기만 AL 자체 전일종가를 쓰면 한 화면에서 등락률이 갈린다.
ohlc_krx_for_render = r.get('ohlc_krx')
if ohlc_krx_for_render:
ohlc_krx_for_render = dict(ohlc_krx_for_render)
@@ -4337,26 +4460,36 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
_krx_prev = r.get('pred_close_krx', 0) or 0
if _krx_prev > 0:
ohlc_krx_for_render['prev_close'] = _krx_prev
ohlc_nxt_for_render = r.get('ohlc_nxt')
_snap_prev = r.get('pred_close', 0) or 0
if ohlc_nxt_for_render and _snap_prev > 0:
ohlc_nxt_for_render = dict(ohlc_nxt_for_render)
ohlc_nxt_for_render['open'] = _snap_prev
ohlc_nxt_for_render['prev_close'] = _snap_prev
_nxt_px = ohlc_nxt_for_render.get('price', 0) or 0
ohlc_nxt_for_render['change'] = _nxt_px - _snap_prev
ohlc_nxt_for_render['change_pct'] = ((_nxt_px - _snap_prev) / _snap_prev * 100) if _snap_prev else 0.0
# 당일종가: KRX는 정규장 마감(15:30) 후, NXT는 마감(20:00) 후에만 그 시장 종가 표시.
ohlc_al_for_render = r.get('ohlc_al')
# 통합 열 전일종가: ①어제 보유였으면 그날 NXT 종가(스냅샷) ②아니면 ka10095 AL 의
# (현재가 − 전일대비). ⚠️ r['pred_close'] 를 쓰면 안 된다 — 신규 매수 종목엔 매수 평단이
# 들어 있어 '전일종가' 라벨이 거짓말이 된다(2026-09-10 우리기술로 실측).
_snap_prev = r.get('pred_close_snap', 0) or 0
if not _snap_prev:
_al_raw = r.get('ohlc_al') or {}
_snap_prev = (_al_raw.get('price', 0) or 0) - (_al_raw.get('change', 0) or 0)
if _snap_prev < 0:
_snap_prev = 0
if ohlc_al_for_render and _snap_prev > 0:
ohlc_al_for_render = dict(ohlc_al_for_render)
# ⚠️ 시가는 덮어쓰지 않는다 — 통합 시가는 NXT 프리마켓(08:00) 첫 체결이라 그 자체가 정보다
# (실측 삼성전자 통합 268,000 vs KRX 269,000). 예전 NXT 열은 시가 칸에 전일종가를
# 넣었는데, 통합 열에서는 진짜 시가를 보여준다.
ohlc_al_for_render['prev_close'] = _snap_prev
_al_px = ohlc_al_for_render.get('price', 0) or 0
ohlc_al_for_render['change'] = _al_px - _snap_prev
ohlc_al_for_render['change_pct'] = ((_al_px - _snap_prev) / _snap_prev * 100) if _snap_prev else 0.0
# 당일종가: KRX는 정규장 마감(15:30) 후, 통합은 NXT 마감(20:00) 후에만 종가 표시.
_now_ohlc = datetime.now(KST)
_hm_ohlc = _now_ohlc.hour * 60 + _now_ohlc.minute
_trd_ohlc = _market_phase_state().get('phase') not in ('holiday', 'weekend')
ohlc_mini_html = _render_ohlc_mini_dual(
ohlc_krx_for_render,
ohlc_nxt_for_render,
ohlc_al_for_render,
active_market=mark.lower(),
al_price=price, # 현재가 행은 AL(통합) 현재가를 양 컬럼 공통 — 등락률만 각 시장 전일종가 대비.
al_price=price, # 현재가 행은 통합 현재가를 양 열 공통 — 등락률만 각 기준 전일종가 대비.
krx_closed=_trd_ohlc and _hm_ohlc >= 15 * 60 + 30,
nxt_closed=_trd_ohlc and _hm_ohlc >= 20 * 60,
al_closed=_trd_ohlc and _hm_ohlc >= 20 * 60,
)
# 미리보기 등락은 rebase 후 day_change_pct 사용 — 어제 NXT 종가 baseline.
@@ -5847,11 +5980,13 @@ details.row + .section-label { margin-top: 14px; padding-top: 24px; border-top-c
.badge.held { background: #2a1820; color: #ff8a96; border: 1px solid rgba(255,77,94,0.3); font-size: 10px; padding: 2px 7px; }
.badge.pending-buy { background: #2a1822; color: #ff9fb3; border: 1px solid rgba(255,140,170,0.32); font-weight: 600; padding: 2px 6px; letter-spacing: 0; }
.badge.pending-sell { background: #182030; color: #7fb8ff; border: 1px solid rgba(122,180,255,0.32); font-weight: 600; padding: 2px 6px; letter-spacing: 0; }
.badge.pending-arm { background: #262013; color: #d9a84a; border: 1px solid rgba(217,168,74,0.35); font-weight: 600; padding: 2px 6px; letter-spacing: 0; }
dl.pending-detail { margin-top: 8px; padding-top: 8px; border-top: 1px dashed rgba(255,255,255,0.08); grid-template-columns: 92px minmax(0, 1fr); }
.row.mode-held .detail dl.pending-detail { grid-template-columns: 92px minmax(0, 1fr); }
dl.pending-detail dt { font-weight: 600; letter-spacing: 0; white-space: nowrap; }
dl.pending-detail dt.pending-buy-label { color: #ff9fb3; }
dl.pending-detail dt.pending-sell-label { color: #7fb8ff; }
dl.pending-detail dt.pending-arm-label { color: #d9a84a; }
dl.pending-detail dd { line-height: 1.45; }
dl.pending-detail .pending-line-main { font-variant-numeric: tabular-nums; }
dl.pending-detail .pending-line-meta { margin-top: 2px; }
@@ -13947,7 +14082,10 @@ window.openPinModal = openPinModal;
.then(function(r){ return r.json(); })
.then(function(d){
setWsStatus(d && d.connected);
if(!d || !d.connected) return;
// 끊기면 마지막 스냅샷을 버린다. 남겨두면 connected:true 인 옛 응답이 그대로 남아
// panels-loaded 훅의 재적용이 서버가 방금 조회한 값을 옛 틱으로 덮어쓴다
// (20:00 NXT 마감 후 허브 종료 → 재렌더를 해도 화면이 19:5x 값에 고착되던 원인).
if(!d || !d.connected){ window.__rtState=null; window.__rtPx=null; return; }
window.__rtState = d;
if(codes.length) applyState(d);
// 체결 신호 변화 → 패널 새로고침(수량·예수금·당일정산 등 사건 기반 값). 첫 관측은 기준값만 저장(공회전 방지).
+20 -6
View File
@@ -222,20 +222,34 @@ def cmd_sync(years_forward: int) -> int:
# 양쪽 다 0건이면 영구 변경/네트워크 차단 의심 — health 카운트 + 2회 연속 시 알림.
both_failed = (not investing_data) and (not nager_data_raw)
_update_health(both_failed, errors)
if not investing_data:
sys.stderr.write('no holidays parsed — investing.com 미반영. state file은 옛 데이터 유지.\n')
if not investing_data and not nager_data_raw:
sys.stderr.write('no holidays parsed — 두 소스 모두 실패. state file은 옛 데이터 유지.\n')
return 3
# KRX는 다음 연도 휴장 캘린더를 보통 11~12월에 공식 발표 → 그 전엔 investing.com도 비어 있음.
# investing이 한 건이라도 잡은 연도에만 nager 보강 — 미발표 연도의 일반 공휴일이 KRX 휴장으로 잘못 등록되는 것 차단.
investing_years = {int(d[:4]) for d in investing_data}
nager_data = {d: n for d, n in nager_data_raw.items() if int(d[:4]) in investing_years}
# 채울 연도 판정. KRX는 다음 연도 휴장 캘린더를 보통 11~12월에 공식 발표하므로,
# 미발표 연도의 일반 공휴일을 KRX 휴장으로 잘못 등록하지 않도록 게이트를 둔다.
# - investing(거래소 캘린더)이 살아 있으면 그게 곧 발표 신호 → 그 연도만 채운다(원래 규칙).
# - investing이 죽었으면(2026-09 이후 403 차단) 달력으로 대신 판정 — 올해는 항상,
# 내년은 11월 이후에만. 이래야 12월→1월 갭도 막고 미발표 연도도 오염되지 않는다.
if investing_data:
allowed_years = {int(d[:4]) for d in investing_data}
else:
now = datetime.now(KST)
allowed_years = {now.year} | ({now.year + 1} if now.month >= 11 else set())
nager_data = {d: n for d, n in nager_data_raw.items() if int(d[:4]) in allowed_years}
# investing.com(거래소 캘린더) 우선 — KRX 휴장 권위. nager는 누락분만 채움.
holidays = {**nager_data, **investing_data}
nager_extra = sorted(d for d in nager_data if d not in investing_data)
# nager는 '공휴일'이라 거래소 전용 휴장인 연말 폐장일(12/31)이 없다. 평일일 때만 보강.
for y in sorted(allowed_years):
eoy = date(y, 12, 31)
key = eoy.isoformat()
if eoy.weekday() < 5 and key not in holidays:
holidays[key] = '연말 휴장'
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
payload = {
'fetched_at': datetime.now(KST).isoformat(),
'sources': {'primary': URL, 'fallback': NAGER_URL.format(year='YYYY')},
'source_used': 'investing+nager' if investing_data else 'nager-only',
'years': years,
'holidays': dict(sorted(holidays.items())),
}
@@ -814,11 +814,30 @@ def get_watchlist_quotes(codes: list[str], account_label: str | None = None,
for i in range(0, len(clean_codes), WATCHLIST_BATCH_MAX):
chunk = clean_codes[i:i + WATCHLIST_BATCH_MAX]
stk_cd_param = '|'.join(f'{c}{suffix}' for c in chunk)
resp = _call(label, TR_WATCHLIST_INFO, {'stk_cd': stk_cd_param}, endpoint=ENDPOINT_STKINFO)
out.update(_parse_watchlist_rows(resp))
out.update(_parse_watchlist_rows(_watchlist_batch_call(label, stk_cd_param)))
return out
def _watchlist_batch_call(label: str, stk_cd_param: str) -> dict:
"""ka10095 1콜 + 429 재시도.
behive_web 은 패널 렌더 1회에 이 TR 을 최대 7콜(보유 KRX/NX/AL · 워치리스트 · NXT 마크
판정 · phantom · 미보유 미체결) 병렬 발사하는데 키움 유량은 초당 5건이라 일부가 429 로
튕긴다. ⚠️ 튕긴 콜은 예외가 호출측에서 로그만 남고 삼켜져 **그 조각이 화면에서 조용히
빈다** — 실측 `NXT positions price fetch failed` 25건, 그때마다 보유종목 NXT 컬럼이
통째로 사라진다(NXT 실거래가 있는 삼성전자·SK하이닉스만 눈에 띈다).
⚠️ ka10001·ka10004 처럼 `_QUOTE_GATE` 로 직렬화하면 7콜이 줄서서 렌더가 1초 이상
느려지므로, 정상 경로는 병렬 그대로 두고 튕긴 콜만 백오프 후 1회 다시 친다.
"""
try:
return _call(label, TR_WATCHLIST_INFO, {'stk_cd': stk_cd_param}, endpoint=ENDPOINT_STKINFO)
except RuntimeError as e:
if not _is_quote_rate_limit(str(e)):
raise
time.sleep(_QUOTE_BACKOFF_SEC)
return _call(label, TR_WATCHLIST_INFO, {'stk_cd': stk_cd_param}, endpoint=ENDPOINT_STKINFO)
def _parse_watchlist_rows(resp: dict) -> dict[str, dict]:
out: dict[str, dict] = {}
for row in resp.get('atn_stk_infr') or []:
@@ -1363,6 +1382,22 @@ def main():
print(f"{info['name']} ({info['code']}) · {info.get('market','?')}")
return 0
if cmd == 'refresh-codes':
# --if-stale N: 캐시가 N일보다 최신이면 건너뛴다(정기 실행용).
# ⚠️ 이 캐시의 nxt_enable 은 자산웹의 'NXT 거래 종목' 판정 근거다(behive_web._is_nxt_traded).
# 낡으면 새로 NXT 편입된 종목을 놓쳐 🔥 배지·거래가능 초록점이 틀어진다(금액엔 영향 없음).
# lazy 갱신은 '모르는 종목명 검색' 때만 돌아, 알던 종목만 다루면 영영 안 바뀐다.
stale_days = 0.0
if '--if-stale' in sys.argv:
try:
stale_days = float(sys.argv[sys.argv.index('--if-stale') + 1])
except (IndexError, ValueError):
print('usage: refresh-codes [--if-stale <days>]', file=sys.stderr)
return 2
if stale_days > 0 and STOCK_CODES_CACHE.exists():
age_days = (time.time() - STOCK_CODES_CACHE.stat().st_mtime) / 86400
if age_days < stale_days:
print(f'종목코드 캐시 {age_days:.1f}일 — {stale_days:g}일 미만이라 갱신 생략')
return 0
stocks = refresh_stock_codes()
print(f"종목코드 캐시 갱신 완료: {len(stocks)}종목 → {STOCK_CODES_CACHE}")
return 0
@@ -79,11 +79,27 @@ from orders.handler import send_telegram # noqa: E402
# 정정 실패 알림 쿨다운 — NXT 시간대에 KRX/SOR 원주문 정정이 거부되면 매 사이클 반복된다.
FAIL_NOTICE_COOLDOWN_SEC = 1800
# 사이클마다 mtime 만 갱신하는 생존 신호. 이 감시가 죽으면 예약 트레일링(ARM)이
# 아예 발동하지 않는데, 예약 0건이면 로그를 한 줄도 남기지 않아 바깥에서 죽었는지
# 구분할 방법이 없었다(2026-08-28 이후 13일간 로그 무갱신이 정상인지 장애인지 불명).
# 로그로 찍으면 분당 1줄 × 720회/일이 쌓여 실제 이벤트를 덮으므로 파일 mtime 만 쓴다.
# scripts/health_check.py 가 이 파일을 읽는다.
HEARTBEAT_FILE = WORKSPACE / 'state' / 'trailing_monitor_heartbeat'
def _log(msg: str) -> None:
print(f'[{datetime.now(KST).strftime("%Y-%m-%d %H:%M:%S")}] {msg}', flush=True)
def _heartbeat() -> None:
"""감시가 이번 사이클을 돌았다는 신호. 실패해도 감시를 막지 않는다."""
try:
HEARTBEAT_FILE.write_text(
datetime.now(KST).isoformat(timespec='seconds'), encoding='utf-8')
except Exception:
pass
def session_now() -> tuple[bool, str]:
"""(감시할 세션인가, 세션명). 주말·휴장일·거래시간 외는 False.
@@ -435,6 +451,7 @@ def _worth_running(force: bool) -> bool:
def check(force: bool = False, dry_run: bool = False) -> int:
_heartbeat()
reservations = trailing.list_active()
arms = trailing.list_arms()
if not reservations and not arms: