auto: 일일 백업 2026-07-03 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-07-03 02:00:02 +09:00
parent 300a4c9ca9
commit dd1ad7d964
170 changed files with 4295 additions and 2635 deletions
+217 -127
View File
@@ -709,6 +709,8 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
out['open'] = snap.get('open')
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_price'] = snap.get('_krx_price', 0)
out['_nxt_price'] = snap.get('_nxt_price', 0)
continue
@@ -748,6 +750,7 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
out['open'] = q.get('open') or None
out['high'] = q.get('high') or None
out['low'] = q.get('low') or None
out['volume'] = q.get('volume')
out['_krx_price'] = price
out['_nxt_price'] = 0
miss_codes, miss_indices = rest_codes, rest_indices
@@ -773,6 +776,8 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
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(miss_indices, miss_codes):
q = batch.get(code)
@@ -788,6 +793,7 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
out['open'] = q.get('open') or None
out['high'] = q.get('high') or None
out['low'] = q.get('low') or None
out['volume'] = q.get('volume')
else:
out['quote_error'] = '가격 0'
# 마크 판정: NXT batch 가격 vs 기본 batch 가격 비교.
@@ -802,6 +808,8 @@ def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
'open': out['open'],
'high': out['high'],
'low': out['low'],
'volume': out.get('volume'),
'_nxt_volume': out.get('_nxt_volume', 0),
'_krx_price': price,
'_nxt_price': nxt_price,
}
@@ -843,6 +851,7 @@ def _fetch_quote(entry: dict) -> dict:
out['open'] = snap.get('open')
out['high'] = snap.get('high')
out['low'] = snap.get('low')
out['volume'] = snap.get('volume')
return out
try:
@@ -855,6 +864,7 @@ def _fetch_quote(entry: dict) -> dict:
out['price'] = price
out['day_change'] = q.get('change')
out['day_change_pct'] = q.get('change_pct')
out['volume'] = q.get('volume')
except Exception as e:
out['quote_error'] = str(e)[:80]
@@ -866,6 +876,7 @@ def _fetch_quote(entry: dict) -> dict:
'open': out['open'],
'high': out['high'],
'low': out['low'],
'volume': out.get('volume'),
}
with _quote_cache_lock:
_quote_cache[target_token] = (snap, time.time() + QUOTE_CACHE_TTL)
@@ -2029,6 +2040,37 @@ def _buy_dd_html(buy_field, alerted: set[str]) -> str:
return ''.join(parts)
def _vol_surge_badge(code: str, today_volume) -> str:
"""오늘 거래량이 직전 20거래일 평균 대비 1.5배 이상이면 🔥 배지(배수 표기).
- 20 평균: dcc._select_latest(code, 21) 순수 SQLite 읽기(렌더 키움 호출 X).
- 장중 환산: sa.session_vol_frac로 정규장 오늘 누적 거래량을 하루치로 투영.
- 데이터 부족(차트 미조회로 캐시 없음 )·임계 미만이면 '' 반환.
"""
if not code or not today_volume:
return ''
try:
import daily_candles_cache as dcc
import stock_analysis as sa
today = datetime.now(KST).strftime('%Y%m%d')
vols = [b['volume'] for b in dcc._select_latest(code, 21)
if str(b.get('date') or '') != today and (b.get('volume') or 0) > 0]
if len(vols) < 5:
return ''
avg20 = sum(vols[:20]) / min(len(vols), 20)
if avg20 <= 0:
return ''
frac = sa.session_vol_frac([{'date': today}])
proj = (today_volume / frac) if frac else today_volume
rvol = proj / avg20
except Exception:
return ''
if rvol < 1.5:
return ''
level = 'red' if rvol >= 3 else 'warm'
return f'<span class="badge vol-hot vol-{level}">🔥{rvol:.1f}</span>'
def _render_row(c: dict, source: str = 'watchlist') -> str:
stock = html.escape(c.get('stock', ''))
code = html.escape(c.get('code', '') or '')
@@ -2274,7 +2316,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}</div>
<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>
<div class="right">
<div class="price">{price_cell}<span class="caret" aria-hidden="true"></span></div>
@@ -2982,7 +3024,7 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
<summary>
<div class="left">
<div class="line1 line1-held">{_buy_rank_badge(buy_amount)}<span class="{stock_cls}">{stock}</span><span class="code acct-mini">{acct_label}</span></div>
<div class="line2"><span class="badge held">📦 {qty:,}</span>{_pending_badges_html(r)}<span class="muted small wt-chip"><span class="rt-weight">{weight:.2f}</span>%</span>{star}</div>
<div class="line2"><span class="badge held">📦 {qty:,}</span>{_pending_badges_html(r)}{_vol_surge_badge(code, (r.get('ohlc_krx') or {}).get('volume'))}<span class="muted small wt-chip"><span class="rt-weight">{weight:.2f}</span>%</span>{star}</div>
{summary_journal_html}
</div>
<div class="right">
@@ -4333,11 +4375,6 @@ header.top {
header.top .titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
header.top h1 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; color: #f0f0f0; animation: refresh-flash 0.8s ease-out; }
header.top .meta { font-size: 11px; color: #8b8f9a; font-variant-numeric: tabular-nums; animation: refresh-flash-meta 0.8s ease-out; }
/* 실시간(WebSocket) 연결 상태 24px 탭영역 안에 10px 컬러 . 초록=연결 / 빨강=끊김 / 회색=확인중 */
.ws-dot { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; flex: none; padding: 0; margin: 0; background: transparent; border: 0; cursor: pointer; }
.ws-dot::before { content: ''; width: 10px; height: 10px; border-radius: 50%; background: #5a5f6c; transition: background 0.3s, box-shadow 0.3s; }
.ws-dot.on::before { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.7); }
.ws-dot.off::before { background: #ff4d5e; box-shadow: 0 0 6px rgba(255,77,94,0.65); }
/* 페이지 로드( 진입·새로고침) 1 발동. 클릭(hashchange) reload 아니므로 발동 . */
@keyframes refresh-flash {
0%, 20% { color: #ff4d5e; }
@@ -4347,54 +4384,28 @@ header.top .meta { font-size: 11px; color: #8b8f9a; font-variant-numeric: tabula
0%, 20% { color: #ff4d5e; }
100% { color: #8b8f9a; }
}
.refresh {
flex: none; display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 36px; border-radius: 8px;
background: #1c2230; border: 1px solid #2a3142; color: #e6e6e6;
font-size: 17px; text-decoration: none; user-select: none;
transition: background 0.15s, transform 0.1s;
}
.refresh:hover { background: #232a3b; }
.refresh:active { transform: scale(0.95); background: #2a3245; }
.refresh.spinning { background: rgba(255,77,94,0.15); border-color: #ff4d5e; color: #ff4d5e; }
.top-actions { display: flex; align-items: center; gap: 8px; flex: none; }
.auto-toggle {
flex: none; display: inline-flex; align-items: center; gap: 6px;
padding: 0 10px; height: 36px; border-radius: 8px;
background: #1c2230; border: 1px solid #2a3142; color: #8b8f9a;
font: 600 12px/1 inherit;
font-variant-numeric: tabular-nums;
cursor: pointer; user-select: none;
transition: background 0.15s, color 0.15s, border-color 0.15s;
/* 자동 갱신 상태 배지 클릭 불가 표시 전용. 장중=초록+남은초, 장마감=흐림. */
.auto-badge {
flex: none; display: inline-flex; align-items: center; justify-content: center;
user-select: none;
}
.auto-toggle:hover { background: #232a3b; }
.auto-toggle:active { transform: scale(0.95); }
.auto-toggle .auto-dot {
width: 7px; height: 7px; border-radius: 50%;
background: #5a5f6c;
transition: background 0.15s, box-shadow 0.15s;
/* 상태 장중 초록 / 장마감 어두운 회색. */
.auto-badge::before {
content: ''; width: 7px; height: 7px; border-radius: 50%;
background: #3a3f4a; transition: background 0.2s, box-shadow 0.2s;
}
.auto-toggle[aria-pressed="true"] {
background: rgba(255,77,94,0.15); border-color: #ff4d5e; color: #ff4d5e;
}
.auto-toggle[aria-pressed="true"] .auto-dot {
background: #ff4d5e; box-shadow: 0 0 6px rgba(255,77,94,0.7);
animation: auto-pulse 1.5s ease-in-out infinite;
}
.auto-toggle.closed,
.auto-toggle.closed:hover {
background: #15171f; border-color: #1f2330; color: #5a5f6c;
cursor: not-allowed;
}
.auto-toggle.closed:active { transform: none; }
.auto-toggle.closed .auto-dot {
background: #3a3f4a; box-shadow: none; animation: none;
}
@keyframes auto-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
.auto-badge.live::before { background: #22c55e; box-shadow: 0 0 5px rgba(34,197,94,0.6); }
.top-trade-btn {
flex: none; display: inline-flex; align-items: center; justify-content: center;
height: 36px; padding: 0 12px; border-radius: 8px;
background: #2a1418; color: #ff8a95; border: 1px solid rgba(255,138,149,0.45);
font-size: 13px; font-weight: 600; line-height: 1; font-family: inherit;
cursor: pointer; user-select: none; white-space: nowrap;
transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s;
}
.top-trade-btn:hover { background: rgba(255,138,149,0.16); border-color: #ff8a95; color: #fff; }
.top-trade-btn:active { transform: scale(0.95); }
.section-label {
padding: 22px 18px 8px; margin-top: 10px;
@@ -4598,6 +4609,8 @@ dl.pending-detail .pending-line-meta { margin-top: 2px; }
.detail .block.chart-svg .chart-tabs button.active { background: rgba(251,191,36,0.12); color: #fbbf24; border-color: #fbbf24; }
.detail .block.chart-svg .chart-panel { display: none; }
.detail .block.chart-svg .chart-panel.active { display: block; }
.detail .block.chart-svg .chart-cap { margin-top: 6px; padding: 6px 4px 0; font-size: 8px; line-height: 1.5; color: #7a8493; }
.detail .block.chart-svg .chart-cap b { color: #9aa3b0; font-weight: 600; }
.detail .block.chart-svg .chart-subtabs { display: flex; gap: 4px; margin-bottom: 6px; flex-wrap: wrap; }
.detail .block.chart-svg .chart-subtabs button { padding: 3px 9px; background: transparent; color: #7a8493; border: 1px solid #2a2f3a; border-radius: 5px; cursor: pointer; font-size: 11px; font-weight: 600; font-family: inherit; }
.detail .block.chart-svg .chart-subtabs button:hover { color: #cfd5df; border-color: #3f4654; }
@@ -4625,6 +4638,9 @@ dl.pending-detail .pending-line-meta { margin-top: 2px; }
.actions .btn-alerts-reset:hover { background: rgba(201,204,211,0.06); border-color: #6aa9ff; color: #6aa9ff; }
.alert-mark { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; color: #ffd66e; background: rgba(255,214,110,0.10); border: 1px solid rgba(255,214,110,0.32); vertical-align: middle; }
.badge.alerted { color: #ffd66e; background: rgba(255,214,110,0.10); border: 1px solid rgba(255,214,110,0.32); padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.badge.vol-hot { padding: 1px 5px; border-radius: 8px; font-size: 9px; font-weight: 700; font-variant-numeric: tabular-nums; }
.badge.vol-hot.vol-warm { color: #f5a623; background: rgba(245,166,35,0.14); border: 1px solid rgba(245,166,35,0.45); }
.badge.vol-hot.vol-red { color: #ef4444; background: rgba(239,68,68,0.16); border: 1px solid rgba(239,68,68,0.5); }
.alert-notice { margin: 0 0 10px; padding: 8px 10px; border-radius: 6px; font-size: 12px; color: #ffd66e; background: rgba(255,214,110,0.08); border-left: 3px solid #ffd66e; }
.buy-raw { color: #c9ccd3; margin-bottom: 4px; }
.buy-level { color: #e6e6e6; line-height: 1.5; }
@@ -5396,18 +5412,24 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
.order-side-toggle button.info-tab { color: #8db4ff; max-width: 100px; }
.order-side-toggle button.info-tab:hover { background: #1f2840; color: #c9e0ff; }
/* 좌우 2-컬럼 강제 (호가창 좁게, 인풋 영역 넓게). flex로 grid 남은 공간 채워 stretch */
.order-body { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.25fr); gap: 10px; align-items: stretch; flex: 1; min-height: 0; }
.order-body { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.25fr); grid-template-rows: minmax(0, 1fr); gap: 10px; align-items: stretch; flex: 1; min-height: 0; }
/* 호가창만 폰트 ~70% 압축, 별도 스크롤. height 100% 인풋 영역과 동일 stretch */
.orderbook { display: flex; flex-direction: column; justify-content: center; border: 1px solid #1f2330; border-radius: 6px; overflow-y: auto; overflow-x: hidden; height: 100%; min-height: 0; scrollbar-width: thin; }
.orderbook::-webkit-scrollbar { width: 4px; }
.orderbook::-webkit-scrollbar-thumb { background: #2a2e3a; border-radius: 2px; }
.orderbook .ob-row { display: grid; grid-template-columns: 1fr 1fr; padding: 1px 5px; font-size: 7.5px; line-height: 1.35; font-variant-numeric: tabular-nums; cursor: pointer; border-bottom: 1px solid #15171e; transition: box-shadow 0.08s; }
.orderbook .ob-row { display: grid; grid-template-columns: 1fr 1fr; padding: 1px 5px; font-size: 7.5px; line-height: 1.35; font-variant-numeric: tabular-nums; cursor: pointer; border-bottom: 1px solid #15171e; transition: box-shadow 0.08s; color: #565c68; }
.orderbook .ob-row:last-of-type { border-bottom: 0; }
.orderbook .ob-row.ask { background: linear-gradient(to left, rgba(201,48,62,0.16), transparent 60%); color: #ff8a95; }
.orderbook .ob-row.bid { background: linear-gradient(to left, rgba(42,95,179,0.16), transparent 60%); color: #8db4ff; }
.orderbook .ob-row:hover { background: #1c1f29; }
.orderbook .ob-row.ob-current { background-color: rgba(230,230,230,0.13); color: #fff; font-weight: 700; box-shadow: inset 3px 0 0 #e6e6e6; }
.orderbook .ob-row.ob-synthetic { background: rgba(230,230,230,0.10); }
.orderbook .ob-row.ob-synthetic .qty { opacity: 0.85; color: #d0d0d0; font-size: 6.5px; }
.orderbook .ob-row.ob-cap { background: #12141b; }
.orderbook .ob-cap-mark { display: inline-block; font-size: 6.5px; font-weight: 800; padding: 0 2px; border-radius: 2px; margin-right: 1px; vertical-align: middle; }
.orderbook .ob-cap-mark.up { color: #ff8a95; background: rgba(201,48,62,0.28); }
.orderbook .ob-cap-mark.down { color: #8db4ff; background: rgba(42,95,179,0.28); }
.orderbook .ob-row.selected { box-shadow: inset 0 0 0 1.5px #ffcc33; color: #fff !important; font-weight: 700; background-color: rgba(255,204,51,0.18) !important; }
.orderbook .ob-divider { padding: 2px 5px; font-size: 8.5px; font-weight: 700; color: #f0f0f0; background: #161922; text-align: center; border-top: 1px solid #1f2330; border-bottom: 1px solid #1f2330; font-variant-numeric: tabular-nums; }
.orderbook .ob-row .price { text-align: left; }
.orderbook .ob-row .qty { text-align: right; opacity: 0.8; font-size: 7px; }
.price-tick-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-top: 4px; }
@@ -5456,9 +5478,6 @@ input[data-order-qty].qty-over { color: #ff8a95; }
.order-actions .btn-confirm[data-side="SELL"] { background: #2a5fb3; }
.order-actions button:active { transform: translateY(1px); }
.order-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
.sub-tab-order-btn { margin-left: auto; background: #2a1418; color: #ff8a95; border: 1px solid rgba(255,138,149,0.45); border-radius: 6px; padding: 6px 14px; font-size: 12px; font-weight: 700; cursor: pointer; font-family: inherit; }
.sub-tab-order-btn:hover { background: rgba(255,138,149,0.16); border-color: #ff8a95; color: #fff; }
.sub-tab-order-btn:active { transform: translateY(1px); }
.pin-box { max-width: 400px; }
.pin-box > .hidden { display: none; }
.open-orders-box { max-width: 560px; }
@@ -6241,7 +6260,6 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
'<button type="button" class="sub-tab active" data-sub-tab="assets" role="tab" aria-selected="true">자산보기</button>'
'<button type="button" class="sub-tab" data-sub-tab="chart" role="tab" aria-selected="false">차트보기</button>'
'<button type="button" class="sub-tab" data-sub-tab="market" role="tab" aria-selected="false">시장정보</button>'
'<button type="button" class="sub-tab-order-btn" data-open-order-modal title="거래 — 종목은 모달에서 선택">💰 거래</button>'
'</div>'
)
@@ -6853,13 +6871,8 @@ def render_html() -> str:
'}'
'window.__behive_set_meta_active=setMetaActive;'
'function setMarket(act){'
'var btn=document.getElementById("auto-toggle");if(!btn)return;'
'var prev=window.__behive_market_active;'
'window.__behive_market_active=!!act;'
'if(act){btn.classList.remove("closed");btn.removeAttribute("disabled");'
'btn.title="10초마다 자동 갱신 (자산정보·관리자·가희·관심종목 탭에서 동작, 페이지를 보고 있을 때만)";}'
'else{btn.classList.add("closed");btn.setAttribute("disabled","");'
'btn.title="휴장 또는 장 마감 (NXT 평일 08:00~20:00 운영) — 자동 갱신 비활성";}'
'if(prev!==!!act&&window.__behive_auto_arm)window.__behive_auto_arm();'
'}'
'window.__behive_set_market=setMarket;'
@@ -7932,27 +7945,24 @@ def render_html() -> str:
'})();</script>'
)
# 자동 갱신 토글 — localStorage 영속, 계좌 탭 보일 때만 __behive_load 호출.
# 사이클: off(0) → 10s(1) → 3s(2) → off. 기존 "1" 값은 10s 모드와 호환.
# 자동 갱신 토글 없이 장 열림 중 항상 3s 주기(백그라운드). 계좌/관심 탭 보일 때만 __behive_load.
# 상단 배지는 상태 점 하나만(클릭 불가): 🟢 장중 자동갱신 / ⚪ 장마감. 카운트다운 없음.
# 장 닫힘 중엔 갱신 멈추고 저속 폴링으로 시장 열림만 감지.
# location.reload() 제거 → 깜빡임·탭 클릭 묻힘 없음. fetch 완료 후에 재무장(arm)해 호출 중첩 방지.
market_active_js = 'true' if market_active else 'false'
auto_reload_script = (
'<script>(function(){'
f'var O=[{auto_refresh_tids_js}];'
f'var MARKET={market_active_js};'
'var INT=[0,10,3],TICK=1000,KEY="behive.autoreload",t=null,remain=0;'
'var SEC=3,TICK=1000,t=null,remain=0;'
# 장 닫힘 동안 시장 열림 감지용 저속 폴링. visible 일 때만 동작.
'var SLOW_MS=60000,slow=null;'
'var btn=document.getElementById("auto-toggle");if(!btn)return;'
'var lbl=btn.querySelector("span:last-child");'
'function mode(){var v=parseInt(localStorage.getItem(KEY)||"0",10);return (v>=0&&v<INT.length)?v:0;}'
'function secs(){return INT[mode()];}'
'function idleLbl(){var s=secs();return s>0?("자동 "+s+"s"):"자동";}'
'var badge=document.getElementById("auto-badge");'
# 배지 점: 장중 초록(.live) / 장마감 어두운 회색. 상태는 marketNow()에만 의존(탭·카운트 무관).
'function dot(){if(badge)badge.classList.toggle("live",marketNow());}'
'function active(){return document.documentElement.getAttribute("data-tab")||"";}'
'function marketNow(){return (typeof window.__behive_market_active!=="undefined")?window.__behive_market_active:MARKET;}'
'function ok(){return marketNow()&&mode()>0&&document.visibilityState==="visible"&&O.indexOf(active())>=0;}'
'function setLbl(s){if(lbl)lbl.textContent=s;}'
'function idleClosedLbl(){return marketNow()?idleLbl():"장마감";}'
'function ok(){return marketNow()&&document.visibilityState==="visible"&&O.indexOf(active())>=0;}'
'function stop(){if(t){clearInterval(t);t=null;}}'
'function stopSlow(){if(slow){clearInterval(slow);slow=null;}}'
'function startSlow(){'
@@ -7966,10 +7976,11 @@ def render_html() -> str:
'function arm(){'
'stop();'
'if(marketNow())stopSlow();else startSlow();'
'if(!ok()){setLbl(idleClosedLbl());return;}'
'remain=secs();setLbl("자동 "+remain+"s");'
'dot();'
'if(!ok())return;'
'remain=SEC;'
't=setInterval(function(){'
'if(!ok()){stop();setLbl(idleClosedLbl());return;}'
'if(!ok()){stop();dot();return;}'
# 차트 툴팁 떠 있는 동안 자동 갱신 미룸 — swap으로 사라지는 거 방지.
'var tt=document.querySelectorAll(".net-chart-tip");'
'for(var ti=0;ti<tt.length;ti++){if(tt[ti].style.opacity==="1")return;}'
@@ -7985,21 +7996,13 @@ def render_html() -> str:
'p.then(arm,arm);'
'return;'
'}'
'setLbl("자동 "+remain+"s");'
'},TICK);'
'}'
'function setUI(m){btn.setAttribute("aria-pressed",m>0?"true":"false");}'
'function set(m){localStorage.setItem(KEY,String(m));setUI(m);arm();}'
'btn.addEventListener("click",function(){'
'if(!marketNow())return;' # 장 닫힘 중엔 토글 무시 (button disabled 안전망)
'set((mode()+1)%INT.length);'
'});'
'window.__behive_auto_arm=arm;'
'document.addEventListener("visibilitychange",arm);'
'addEventListener("hashchange",arm);'
# 첫 진입 시 휴장·주말·장외이면 button에 closed/disabled 즉시 적용 — panels fetch 전에도 시각 일관.
'if(window.__behive_set_market)window.__behive_set_market(MARKET);'
'setUI(mode());arm();'
'arm();'
'})();</script>'
)
@@ -8537,7 +8540,7 @@ def render_html() -> str:
'\'<span class="ti-sep">·</span>\'+meta+\'</div>\'+'
'\'<span class="ti-pl muted">-</span></div>\'+'
'\'<div class="ti-row2"><div class="ti-data">\'+'
'\'<span class="ti-price"><span class="ti-label">평균가</span> \'+fmtInt(r.buy_avg)+\'원</span><span class="ti-sep">·</span>\'+'
'\'<span class="ti-price"><span class="ti-label">매수가</span> \'+fmtInt(r.buy_avg)+\'원</span><span class="ti-sep">·</span>\'+'
'\'<span class="ti-qty"><span class="ti-label">수량</span> \'+fmtInt(r.buy_qty)+\'주</span><span class="ti-sep">·</span>\'+'
'\'<span class="ti-amt"><span class="ti-label">금액</span> \'+fmtInt(r.buy_amt)+\'원</span></div></div>\'+'
'\'</li>\';'
@@ -8546,16 +8549,23 @@ def render_html() -> str:
'var pl=r.pl_amt||0;'
'var plCls=pl>0?"pl-pos":(pl<0?"pl-neg":"muted");'
'var sgn=pl>0?"+":"";'
'var reNote=r._pl_rebased?\'<div class="muted small" style="margin-top:2px">원가 \'+fmtInt(r.prev_avg)+\'원(전일 보유 평단) 기준 · 당일 매수분 제외</div>\':"";'
'var costAvg=r._pl_rebased?r.prev_avg:Math.round(r.sell_avg-(pl+(r.cmsn_tax||0))/r.sell_qty);'
'var costPct=costAvg>0?(r.sell_avg-costAvg)/costAvg*100:0;'
'var costCol=costPct>0?"#ff4d5e":(costPct<0?"#2c7be5":"#6c7180");'
'var costSgn=costPct>0?"+":"";'
'var reNote=r._pl_rebased?\'<div class="muted small" style="margin-top:2px;font-size:11px">원가 \'+fmtInt(r.prev_avg)+\'원(전일 보유 평단) 기준 · 당일 매수분 제외</div>\':"";'
'out+=\'<li class="trade-item trade-item-sell\'+seedCls+\'">\'+stockLine+'
'\'<div class="ti-row1"><div class="ti-meta">\'+'
'\'<span class="ti-side side-sell">매도</span>\'+'
'\'<span class="ti-sep">·</span>\'+meta+\'</div>\'+'
'\'<span class="ti-pl \'+plCls+\'">\'+sgn+fmtInt(pl)+\'원</span></div>\'+'
'\'<div class="ti-row2"><div class="ti-data">\'+'
'\'<span class="ti-price"><span class="ti-label">평균가</span> \'+fmtInt(r.sell_avg)+\'원</span><span class="ti-sep">·</span>\'+'
'\'<span class="ti-price"><span class="ti-label">매도가</span> \'+fmtInt(r.sell_avg)+\'원</span><span class="ti-sep">·</span>\'+'
'\'<span class="ti-qty"><span class="ti-label">수량</span> \'+fmtInt(r.sell_qty)+\'주</span><span class="ti-sep">·</span>\'+'
'\'<span class="ti-amt"><span class="ti-label">금액</span> \'+fmtInt(r.sell_amt)+\'원</span></div></div>\'+reNote+'
'\'<span class="ti-amt"><span class="ti-label">금액</span> \'+fmtInt(r.sell_amt)+\'원</span></div></div>\'+'
'\'<div class="ti-row2"><div class="ti-data">\'+'
'\'<span class="ti-price"><span class="ti-label">평단가</span> \'+fmtInt(costAvg)+\'원</span><span class="ti-sep">·</span>\'+'
'\'<span class="ti-price" style="color:\'+costCol+\'">\'+costSgn+costPct.toFixed(2)+\'%</span></div></div>\'+reNote+'
'\'</li>\';'
'}'
'return out;'
@@ -9037,34 +9047,74 @@ function renderBook(book){
if(book.change>0) che.classList.add('up'); else if(book.change<0) che.classList.add('down');
}
var ob = $('[data-orderbook]'); if(!ob) return;
var html = '';
var asks = (book.asks||[]).slice().reverse();
for(var i=0;i<asks.length;i++){
var a = asks[i];
html += '<div class="ob-row ask" data-ob-price="'+a.price+'"><span class="price">'+fmt(a.price)+'</span><span class="qty">'+fmt(a.qty)+'</span></div>';
}
html += '<div class="ob-divider">'+fmt(book.price)+'원</div>';
var prevScroll = ob.scrollTop; // 가격 변동에도 스크롤 고정
var cur = book.price || 0;
var asks = (book.asks||[]).slice().reverse(); // 높은 매도호가 최우선(낮은) 아래
var bids = book.bids||[];
for(var j=0;j<bids.length;j++){
var b = bids[j];
html += '<div class="ob-row bid" data-ob-price="'+b.price+'"><span class="price">'+fmt(b.price)+'</span><span class="qty">'+fmt(b.qty)+'</span></div>';
var inBook = false;
for(var t=0;t<asks.length;t++){ if(asks[t].price===cur) inBook=true; }
for(var u=0;u<bids.length;u++){ if(bids[u].price===cur) inBook=true; }
var html = '';
if(book.upper_limit > 0) html += capRowHtml(book.upper_limit, 'up'); // 상한가
if(cur > 0 && !inBook){
// 현재가가 20호가 어디에도 없음 현재가 중심 사다리로 재구성(잔량은 겹치는 호가에 표시).
html += buildCenteredRows(asks, bids, cur);
} else {
// 평소: 매도10 + 매수10 (=20). 현재가는 일치 호가에 하이라이트.
for(var i2=0;i2<asks.length;i2++){ var a=asks[i2];
html += '<div class="ob-row ask'+(a.price===cur?' ob-current':'')+'" data-ob-price="'+a.price+'"><span class="price">'+fmt(a.price)+'</span><span class="qty">'+fmt(a.qty)+'</span></div>'; }
for(var j2=0;j2<bids.length;j2++){ var b=bids[j2];
html += '<div class="ob-row bid'+(b.price===cur?' ob-current':'')+'" data-ob-price="'+b.price+'"><span class="price">'+fmt(b.price)+'</span><span class="qty">'+fmt(b.qty)+'</span></div>'; }
}
if(book.lower_limit > 0) html += capRowHtml(book.lower_limit, 'down'); // 아래 하한가
ob.innerHTML = html;
syncMarketPriceInput(book);
highlightSelectedTick(); updateMarketPhaseDisplay(); decorateAccounts();
if(!state.bookCentered){
var anchor = ob.querySelector('.ob-current') || ob.querySelector('.ob-row.bid');
if(anchor){ ob.scrollTop = Math.max(0, anchor.offsetTop + anchor.offsetHeight/2 - ob.clientHeight/2); state.bookCentered = true; }
} else { ob.scrollTop = prevScroll; }
}
function syncMarketPriceInput(book){
var otSel = $('[data-order-type]');
if(otSel && otSel.value === 'MARKET' && book.price){
var inp = $('[data-order-price-input]');
if(inp && parseInt(inp.value||'0',10) !== book.price) inp.value = book.price;
}
highlightSelectedTick();
updateMarketPhaseDisplay();
decorateAccounts(); // 현재가 갱신 매수 예수금 충분 여부 갱신
if(!state.bookCentered){
var div = ob.querySelector('.ob-divider');
if(div){
ob.scrollTop = Math.max(0, div.offsetTop + div.offsetHeight/2 - ob.clientHeight/2);
state.bookCentered = true;
}
}
// 한국 주식 호가단위 (2023 KRX 개정, KOSPI/KOSDAQ 통일)
function tickSize(p){
if(p < 2000) return 1;
if(p < 5000) return 5;
if(p < 20000) return 10;
if(p < 50000) return 50;
if(p < 200000) return 100;
if(p < 500000) return 500;
return 1000;
}
// 현재가가 호가에 없을 : 현재가 중심 ±10 사다리(20). 잔량은 겹치는 호가에만, 현재가 하이라이트.
function buildCenteredRows(asks, bids, cur){
var qmap = {};
for(var i=0;i<asks.length;i++) qmap[asks[i].price] = {q:asks[i].qty, side:'ask'};
for(var j=0;j<bids.length;j++) qmap[bids[j].price] = {q:bids[j].qty, side:'bid'};
var up = [], p = cur;
for(var a=0;a<10;a++){ p += tickSize(p); up.push(p); } // 현재가 10
var dn = [], q = cur;
for(var b=0;b<10;b++){ q -= tickSize(q>1?q-1:q); if(q<1) break; dn.push(q); } // 현재가 아래 10
var rows = up.reverse().concat([cur]).concat(dn); // 높은가격 낮은가격 아래
var html = '';
for(var r=0;r<rows.length;r++){
var pr = rows[r], m = qmap[pr];
var cls = pr===cur ? 'bid ob-current' : (m ? m.side : '');
var qty = m ? fmt(m.q) : '';
html += '<div class="ob-row '+cls+'" data-ob-price="'+pr+'"><span class="price">'+fmt(pr)+'</span><span class="qty">'+qty+'</span></div>';
}
return html;
}
function capRowHtml(price, dir){
var mk = dir==='up' ? '<b class="ob-cap-mark up">상</b> ' : '<b class="ob-cap-mark down">하</b> ';
var lab = dir==='up' ? '상한' : '하한';
return '<div class="ob-row ob-cap ob-cap-'+dir+'" data-ob-price="'+price+'"><span class="price">'+mk+fmt(price)+'</span><span class="qty">'+lab+'</span></div>';
}
function pollOnce(){
if(document.hidden || !state.isOpen || !state.code) return;
@@ -10058,9 +10108,8 @@ window.openPinModal = openPinModal;
<span class="meta">갱신 {now}</span>
</div>
<div class="top-actions">
<button type="button" id="ws-status" class="ws-dot" title="실시간 연결 상태 (탭하면 표시)" aria-label="실시간 연결 상태"></button>
<button id="auto-toggle" class="auto-toggle" type="button" aria-pressed="false" title="자동 갱신 사이클: off → 10s → 3s (자산정보·관리자·가희·관심종목 탭에서 동작, 페이지를 보고 있을 때만)"><span class="auto-dot"></span><span>자동</span></button>
<a class="refresh" href="#" onclick="event.preventDefault();window.__behive_manual_refresh&&window.__behive_manual_refresh();" aria-label="새로고침" title="새로고침"></a>
<span id="auto-badge" class="auto-badge" title="초록 = 장중 3초마다 자동 갱신 / 회색 = 장마감"></span>
<button type="button" class="top-trade-btn" data-open-order-modal title="거래 — 종목은 모달에서 선택">거래</button>
<div class="opts-wrap">
<button type="button" id="opts-toggle" class="opts-btn" aria-haspopup="true" aria-expanded="false" aria-label="옵션" title="옵션"></button>
<div class="opts-popup" id="opts-popup" hidden role="menu" aria-label="옵션">
@@ -10443,7 +10492,6 @@ class Handler(BaseHTTPRequestHandler):
import time as _time
from orders.pin import PinStore
pending = PinStore().peek()
unique_codes: set = set()
result: dict = {'active': False}
if pending is not None and not pending.is_expired() and not pending.consumed:
payload = pending.payload or {}
@@ -10466,21 +10514,18 @@ class Handler(BaseHTTPRequestHandler):
'card_message': card_message,
'expiry_seconds': remaining,
}
if payload.get('symbol'):
unique_codes.add(payload['symbol'])
# 4계좌 미체결 주문 ka10075 통합
open_orders_count = 0
try:
import kiwoom_client as kc
rows = kc.get_open_orders_all()
open_orders_count = len(rows)
for r in rows:
if r.get('code'):
unique_codes.add(r['code'])
except Exception:
traceback.print_exc()
result['open_orders_count'] = open_orders_count
result['unique_count'] = len(unique_codes)
# 진행중 아이템 수 = 미체결 주문 건수(팝업 행과 동일) + 활성 PIN 카드(있으면 1).
# 종목코드 중복제거 X — 같은 종목 2계좌 매도도 2건으로 표시(팝업과 일치).
result['unique_count'] = open_orders_count + (1 if result.get('active') else 0)
self._send_json(200, result)
except Exception as e:
traceback.print_exc()
@@ -10503,18 +10548,36 @@ class Handler(BaseHTTPRequestHandler):
return
try:
import kiwoom_client as kc
book = kc.get_quote_book(code)
quote = kc.get_stock_quote(code)
# NXT 거래 가능 여부 먼저 판정 (거래소 선택에 사용). 캐시 없거나 모르면 보수적 True.
try:
meta = kc.lookup_stock_meta(code)
nxt_enable = True if (not meta) else bool(meta.get('nxt_enable', True))
except Exception:
nxt_enable = True
# NXT 시간대엔 KRX 호가창이 15:30 종가로 고정 → 현재가(NXT)와 갭 발생.
# 그 시간대엔 NXT 호가창·NXT 현재가로 정합시킨다. 단 NXT 미상장 종목은 NXT 호가가
# 없으므로 그 시간대에도 KRX 기준으로 표시.
_ph = _market_phase_state().get('phase')
use_nxt = (_ph == 'nxt') and nxt_enable
if use_nxt:
book = kc.get_quote_book(code + '_NX')
quote = kc.get_stock_quote(code, exchange='NX')
else:
book = kc.get_quote_book(code)
quote = kc.get_stock_quote(code, exchange='KRX')
book['price'] = quote.get('price', 0)
book['change'] = quote.get('change', 0)
book['change_pct'] = quote.get('change_pct', 0.0)
book['name'] = quote.get('name', '')
# NXT 거래 가능 여부 (ka10099 캐시 nxtEnable) — 캐시 없거나 모르면 보수적 True
# 상한가·하한가 (ka10001 upl_pric/lst_pric) — 사다리 호가 생성 범위
qraw = quote.get('raw') if isinstance(quote.get('raw'), dict) else {}
try:
meta = kc.lookup_stock_meta(code)
book['nxt_enable'] = True if (not meta) else bool(meta.get('nxt_enable', True))
except Exception:
book['nxt_enable'] = True
book['upper_limit'] = abs(int(str(qraw.get('upl_pric') or '0').strip() or '0'))
book['lower_limit'] = abs(int(str(qraw.get('lst_pric') or '0').strip() or '0'))
except (ValueError, TypeError):
book['upper_limit'] = 0
book['lower_limit'] = 0
book['nxt_enable'] = nxt_enable # 위에서 판정한 값 재사용
book.pop('raw', None)
except Exception as e:
traceback.print_exc()
@@ -10791,6 +10854,14 @@ class Handler(BaseHTTPRequestHandler):
tq = qmap.get(code) if isinstance(qmap, dict) else None
except Exception:
tq = None
# 오늘 봉 거래량은 KRX 기준(일봉 히스토리와 동일 기준 → 막대·RVOL·회전율 일관).
# 가격 OHLC는 통합(AL) 유지, 거래량만 KRX.
krx_vol = 0
try:
_qk = kc.get_watchlist_quotes([code], exchange='KRX').get(code) or {}
krx_vol = _qk.get('volume', 0) or 0
except Exception:
krx_vol = 0
live_today_ok = bool(
tq and tq.get('price') and tq.get('open')
@@ -10804,7 +10875,7 @@ class Handler(BaseHTTPRequestHandler):
'high': tq['high'],
'low': tq['low'],
'close': tq['price'],
'volume': tq.get('volume', 0),
'volume': krx_vol or tq.get('volume', 0),
'value': 0,
'turnover_rate': None,
}]
@@ -10820,6 +10891,24 @@ class Handler(BaseHTTPRequestHandler):
'sma20': sa.sma(closes, 20),
'sma60': sa.sma(closes, 60),
}
# 마지막 봉이 오늘·정규장 진행형이면 세션경과 비율로 오늘 거래량을 하루치 환산(회전율 '예상')
vfrac = None
if candles_asc[-1]['date'] == today and _market_phase_state().get('phase') == 'regular':
now = datetime.now(KST)
hm = now.hour * 60 + now.minute
vfrac = min(1.0, max(0.15, (hm - 9 * 60) / 390.0)) # 09:00~15:30 = 390분
# 오늘 회전율·RVOL 값 — 차트 아래 표시 (텍스트 라벨은 차트에 안 그림)
_turn, _tscaled = sa.turnover_today(candles_asc, vfrac)
_rvol, _rscaled = sa.rvol_today(candles_asc, vfrac)
_cap_bits = []
if _rvol is not None:
_cap_bits.append(f'<b>RVOL</b> x{_rvol:.1f}')
if _turn is not None:
_cap_bits.append(f'<b>회전율</b> {_turn:.2f}%')
_turn_cap = (
f'<div class="chart-cap">{" · ".join(_cap_bits)}{" (예상)" if (_tscaled or _rscaled) else ""}</div>'
if _cap_bits else ''
)
# 분석 페이지와 동일 패턴 — 1M/3M/6M/1Y 4 개 panel + 탭. 250 봉 캐시 슬라이스만 다름.
# 분봉 panel 은 빈 placeholder. 탭 클릭 시 클라이언트가 lazy fetch.
svg_1y = sa.render_svg_chart(snap, range_key='1Y')
@@ -10856,6 +10945,7 @@ class Handler(BaseHTTPRequestHandler):
f'<div class="chart-panel" data-range="3M">{svg_3m}</div>'
f'<div class="chart-panel" data-range="6M">{svg_6m}</div>'
f'<div class="chart-panel" data-range="1Y">{svg_1y}</div>'
+ _turn_cap
)
body = html_block.encode('utf-8')
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))