@@ -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 ` 는 호출마다 954 KB 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 : 8 px ; padding - top : 8 px ; border - top : 1 px dashed rgba ( 255 , 255 , 255 , 0.08 ) ; grid - template - columns : 92 px minmax ( 0 , 1 fr ) ; }
. row . mode - held . detail dl . pending - detail { grid - template - columns : 92 px minmax ( 0 , 1 fr ) ; }
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 : 2 px ; }
@@ -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 : 5 x 값에 고착되던 원인 ) .
if ( ! d | | ! d . connected ) { window . __rtState = null ; window . __rtPx = null ; return ; }
window . __rtState = d ;
if ( codes . length ) applyState ( d ) ;
/ / 체결 신호 변화 → 패널 새로고침 ( 수량 · 예수금 · 당일정산 등 사건 기반 값 ) . 첫 관측은 기준값만 저장 ( 공회전 방지 ) .