auto: 일일 백업 2026-07-23 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -806,6 +806,16 @@ def _market_phase_state() -> dict:
|
||||
return {'phase': 'closed', 'label': '장외 시간', 'active': False, 'time': time_str}
|
||||
|
||||
|
||||
def _bump_panel_signal() -> None:
|
||||
"""주문 접수/취소 등 이벤트 발생 시 state/fill_signal 갱신 → 브라우저 1초 틱(realtime)이
|
||||
fill_seq 변화를 감지해 패널을 1회 갱신. 3초 주기 전면 swap 폐지(2026-07-22, 이벤트 기반 전환)
|
||||
후 미체결·PIN 배지 신선도 유지용. fill_watcher 가 체결 시 쓰는 같은 파일. 실패 무시."""
|
||||
try:
|
||||
(WORKSPACE / 'state' / 'fill_signal').write_text(str(time.time()), encoding='utf-8')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _is_preopen_market_day() -> bool:
|
||||
"""거래일 08:00 전. 장전 가격으로 당일 손익을 만들지 않는다."""
|
||||
now = datetime.now(KST)
|
||||
@@ -2662,8 +2672,11 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
|
||||
stop_raw += ' <span class="alert-mark">알림됨</span>'
|
||||
|
||||
chart_block = ''
|
||||
flow_block = ''
|
||||
if code:
|
||||
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
|
||||
# 외국인·기관·개인 수급 (ka10059) — 팝업 열 때 client lazy fetch (flow_script)
|
||||
flow_block = f'<div class="block flow-box" data-flow-code="{code}"></div>'
|
||||
|
||||
summary_lines = c.get('summary') or []
|
||||
summary_block = ''
|
||||
@@ -2800,6 +2813,7 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
|
||||
</summary>
|
||||
<div class="detail">
|
||||
{detail_name_html}
|
||||
{f'<div class="detail-simple"><span class="ds-lbl">현재가</span><span class="ds-val">{price_cell}</span></div>' if price is not None else ''}
|
||||
{'<div class="alert-notice">최초 알림 완료 — 리셋 전까지 매수 알림 없음</div>' if unheld_done else ''}
|
||||
<div class="grid2">
|
||||
{held_row}
|
||||
@@ -2814,6 +2828,7 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
|
||||
{notes_block}
|
||||
{video_block}
|
||||
{actions_html}
|
||||
{flow_block}
|
||||
{chart_block}
|
||||
</div>
|
||||
</details>'''
|
||||
@@ -3297,6 +3312,10 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
if _t and _t not in _acct_types:
|
||||
_acct_types.append(_t)
|
||||
acct_label = html.escape('·'.join(_acct_types))
|
||||
# 소유자 — 계좌 라벨 '가희_' 접두로 판별. 팝업(탭 안 보임)에서 누구 보유인지 배지로 표시.
|
||||
_is_gahee = any((lb or '').startswith('가희') for lb in r.get('accounts', []))
|
||||
owner_disp = '가희' if _is_gahee else '관리자'
|
||||
owner_cls = 'gahee' if _is_gahee else 'self'
|
||||
qty = r.get('qty', 0)
|
||||
avg = r.get('avg', 0)
|
||||
price = r.get('price', 0)
|
||||
@@ -3438,14 +3457,17 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
price_html = f'{mark_html}<span class="rt-px">{price:,}</span>'
|
||||
|
||||
chart_block = ''
|
||||
flow_block = ''
|
||||
if code:
|
||||
# 카드 펼침 이벤트에 클라이언트 JS 가 /api/chart_svg?code= 를 fetch 해 채움.
|
||||
# data-chart-code 속성으로 식별. panels swap 후 chartCache 복원도 같은 셀렉터로.
|
||||
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
|
||||
# 외국인·기관·개인 수급 (ka10059) — 팝업 열 때 client lazy fetch (flow_script)
|
||||
flow_block = f'<div class="block flow-box" data-flow-code="{code}"></div>'
|
||||
|
||||
left_pairs = [
|
||||
f'<dt>종목코드</dt><dd class="muted">{code}</dd>',
|
||||
f'<dt>현재가</dt><dd class="num">{price:,}원</dd>',
|
||||
f'<dt>현재가</dt><dd class="num"><span class="rt-px">{price:,}</span>원</dd>',
|
||||
f'<dt>평단가</dt><dd class="num">{avg:,}원</dd>',
|
||||
]
|
||||
right_pairs = [
|
||||
@@ -3518,13 +3540,15 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
{candle_summary}
|
||||
</summary>
|
||||
<div class="detail">
|
||||
<div class="detail-name">{stock}{f'<span class="detail-code">{code}</span>' if code else ''}{tag_add_btn}</div>
|
||||
<div class="detail-name">{stock}{f'<span class="detail-code">{code}</span>' if code else ''}<span class="detail-owner owner-{owner_cls}">{owner_disp}</span>{tag_add_btn}</div>
|
||||
{f'<div class="detail-tag">{tag_chip}</div>' if tag_chip else ''}
|
||||
{f'<div class="detail-simple"><span class="ds-lbl">현재가</span><span class="ds-val">{price_html}</span></div>' if price else ''}
|
||||
<dl>{dl_inner}</dl>
|
||||
<dl class="pl-summary">{pl_dl_inner}</dl>
|
||||
{ohlc_mini_html}
|
||||
{_pending_detail_html(r)}
|
||||
{trade_btn}
|
||||
{flow_block}
|
||||
{chart_block}
|
||||
</div>
|
||||
</details>'''
|
||||
@@ -4965,12 +4989,15 @@ details.row + .section-label { margin-top: 14px; padding-top: 24px; border-top-c
|
||||
.ohlc-mini .ohlc-pct.down { color: #4a8cf0; }
|
||||
.ohlc-mini .ohlc-pct.neutral { color: #8b8f9a; }
|
||||
/* 보유종목 자세히 보기 — KRX/NXT 두 거래소 분리 표시. 3-col grid (라벨/KRX셀/NXT셀). */
|
||||
.ohlc-mini.ohlc-dual { grid-template-columns: max-content 1fr 1fr; column-gap: 16px; }
|
||||
/* 팝업 폭 대응 — 라벨(전일종가/현재가/…) 열을 고정폭으로 좁히고 KRX/NXT 열을 넓혀 값 폰트(13/12) 유지.
|
||||
단가 큰 종목(예: 하이닉스)도 nowrap 으로 한 줄 유지. */
|
||||
.ohlc-mini.ohlc-dual { grid-template-columns: 48px 1fr 1fr; column-gap: 6px; }
|
||||
.ohlc-mini.ohlc-dual .ohlc-rl { font-size: 11px; white-space: nowrap; }
|
||||
.ohlc-mini.ohlc-dual .ohlc-th { text-align: center; border-bottom: 1px solid #1f2330; }
|
||||
.ohlc-mini.ohlc-dual .ohlc-th:first-child { text-align: left; border-bottom: none; }
|
||||
.ohlc-mini.ohlc-dual .ohlc-cell { display: flex; flex-direction: row; align-items: baseline; justify-content: flex-end; gap: 8px; }
|
||||
.ohlc-mini.ohlc-dual .ohlc-cell .ohlc-price { font-size: 13px; text-align: right; }
|
||||
.ohlc-mini.ohlc-dual .ohlc-cell .ohlc-pct { font-size: 12px; min-width: auto; }
|
||||
.ohlc-mini.ohlc-dual .ohlc-cell { display: flex; flex-direction: row; align-items: baseline; justify-content: flex-end; gap: 4px; min-width: 0; }
|
||||
.ohlc-mini.ohlc-dual .ohlc-cell .ohlc-price { font-size: 13px; text-align: right; white-space: nowrap; }
|
||||
.ohlc-mini.ohlc-dual .ohlc-cell .ohlc-pct { font-size: 12px; min-width: auto; white-space: nowrap; }
|
||||
.ohlc-mini.ohlc-dual .ohlc-cell-empty { color: #5a5e69; text-align: right; font-size: 12px; }
|
||||
/* 비활성 시장 컬럼 회색 처리 — 헤더·셀·가격·등락률 모두 흐리게. */
|
||||
.ohlc-mini.ohlc-dual .ohlc-th.ohlc-dim { color: #4a4e58; }
|
||||
@@ -5003,9 +5030,21 @@ details.row + .section-label { margin-top: 14px; padding-top: 24px; border-top-c
|
||||
/* 펼친 detail의 태그는 크게 (미리보기·관심행 칩은 작게 유지). */
|
||||
.detail-name { font-size: 15px; font-weight: 700; color: #f0f0f0; margin: 0 0 8px; display: flex; align-items: baseline; gap: 8px; }
|
||||
.detail-name .detail-code { font-size: 11px; font-weight: 500; color: #8b8f9a; font-variant-numeric: tabular-nums; }
|
||||
.detail-name .detail-owner { font-size: 11px; font-weight: 600; border-radius: 4px; padding: 1px 6px; align-self: center; }
|
||||
.detail-name .detail-owner.owner-self { color: #6aa9ff; background: rgba(106,169,240,0.12); border: 1px solid rgba(106,169,240,0.4); }
|
||||
.detail-name .detail-owner.owner-gahee { color: #e879f9; background: rgba(232,121,249,0.12); border: 1px solid rgba(232,121,249,0.4); }
|
||||
.detail-name .btn-add-tag { align-self: center; }
|
||||
.detail-tag { margin: 0 0 8px; }
|
||||
.detail-tag .tag-chip { font-size: 11px; padding: 2px 8px; border-radius: 4px; }
|
||||
/* 심플정보 — 팝업 상단에 접힌 행(summary)의 종목 기본정보(현재가·등락) 재노출. 팝업 전환으로 summary가 백드롭 뒤로 가려져 보이던 정보. */
|
||||
.detail .detail-simple { display: flex; align-items: baseline; gap: 8px; margin: 0 0 10px; padding: 8px 10px; background: #131820; border: 1px solid #1f2330; border-radius: 8px; }
|
||||
.detail .detail-simple .ds-lbl { font-size: 11px; font-weight: 700; color: #8b8f9a; }
|
||||
.detail .detail-simple .ds-val { font-size: 16px; font-weight: 700; color: #f0f0f0; font-variant-numeric: tabular-nums; display: inline-flex; align-items: baseline; gap: 6px; }
|
||||
.detail .detail-simple .ds-val .day-pct { font-size: 13px; font-weight: 600; }
|
||||
/* 팝업 상단 sticky 헤더 (JS가 detail-name + detail-simple 을 묶어 최상단 고정) — 종목명·소유자·현재가 항상 노출 */
|
||||
.detail .detail-header { position: sticky; top: 0; z-index: 4; margin: 0 -16px 8px; padding: 12px 16px 8px; background: #0d1018; border-bottom: 1px solid #1f2330; }
|
||||
.detail .detail-header .detail-name { margin: 0; }
|
||||
.detail .detail-header .detail-simple { margin: 6px 0 0; }
|
||||
.btn-add-tag { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; line-height: 1.5; color: #5a5f6c; background: transparent; border: 1px dashed rgba(120,124,135,0.30); white-space: nowrap; flex: none; cursor: pointer; font-family: inherit; }
|
||||
.btn-add-tag:hover { color: #d6b34a; border-color: rgba(214,179,74,0.45); }
|
||||
.tag-quick-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
@@ -5035,6 +5074,50 @@ dl.pending-detail .pending-line-meta { margin-top: 2px; }
|
||||
border-top: 1px solid #14171f;
|
||||
font-size: 13px;
|
||||
}
|
||||
/* 종목 상세 팝업(모달) — <details> open 시 .detail 을 화면 중앙 오버레이 카드로 띄움.
|
||||
PC·모바일 공통 중앙 카드(작게). 닫기(✕)는 팝업 내부 sticky 바 — 크기·스크롤과 무관하게 우상단 고정.
|
||||
차트/크로스헤어/mutex/상태보존 JS는 그대로. */
|
||||
#detail-backdrop { position: fixed; inset: 0; z-index: 900; background: rgba(0,0,0,0.62); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); }
|
||||
#detail-backdrop[hidden] { display: none; }
|
||||
body.detail-open { overflow: hidden; }
|
||||
details.row[open] > .detail {
|
||||
position: fixed; z-index: 950;
|
||||
top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
width: min(520px, calc(100% - 32px));
|
||||
max-height: calc(100dvh - 120px); overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch;
|
||||
border: 2px solid #ffffff; border-radius: 14px;
|
||||
box-shadow: 0 18px 50px rgba(0,0,0,0.5);
|
||||
padding-top: 0; padding-bottom: 0; /* 상단 헤더·하단 액션 바가 가장자리에 딱 붙도록 위·아래 여백 제거 */
|
||||
}
|
||||
/* 팝업 하단 sticky 액션 바 (JS가 열린 .detail 의 .actions 를 맨 끝으로 옮기고 이 클래스 부여) — 메모·상세·거래내역·거래 + 닫기, 항상 노출 */
|
||||
.detail .actions.detail-actions-footer { position: sticky; bottom: 0; z-index: 3; margin: 8px -16px 0; padding: 8px 16px 10px; gap: 6px; background: #0d1018; border-top: 1px solid #1f2330; }
|
||||
/* 닫기 버튼 — 하단 바 마지막 항목. 우측으로 밀어 다른 버튼과 구분 */
|
||||
.detail .actions .btn-detail-close { margin-left: auto; color: #e6e9ef; border-color: #3a4154; }
|
||||
.detail .actions .btn-detail-close:hover { background: #22262f; border-color: #6aa9ff; color: #fff; }
|
||||
/* 종목 변경 드롭다운 — 하단 바 버튼 위(자기 행 차지) */
|
||||
.detail .actions .detail-stock-switch { flex: 1 1 100%; min-width: 0; padding: 7px 10px; background: #0a0d12; color: #e6e9ef; border: 1px solid #2a3142; border-radius: 6px; font-family: inherit; font-size: 14px; }
|
||||
.detail .actions .detail-stock-switch:focus { outline: none; border-color: #6aa9ff; }
|
||||
/* 외국인·기관·개인 수급 블록 (팝업 lazy fetch) */
|
||||
.detail .flow-box { margin-top: 14px; }
|
||||
.flow-title { font-size: 12px; font-weight: 700; color: #cfd3db; margin-bottom: 6px; }
|
||||
.flow-title .flow-unit { font-size: 10px; font-weight: 500; color: #7a8493; }
|
||||
.flow-title.flow-err { color: #8b8f9a; font-weight: 500; }
|
||||
.flow-tbl { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
||||
.flow-tbl th { font-size: 10px; font-weight: 500; color: #8b8f9a; text-align: right; padding: 3px 4px; border-bottom: 1px solid #1f2330; }
|
||||
.flow-tbl th:first-child { text-align: left; }
|
||||
.flow-tbl td { font-size: 12px; padding: 4px; text-align: right; border-bottom: 1px solid #14171f; }
|
||||
.flow-tbl td.flow-d { text-align: left; color: #8b8f9a; }
|
||||
.flow-tbl td.up { color: #ff4d5e; }
|
||||
.flow-tbl td.down { color: #4a8cf0; }
|
||||
.flow-tbl td.neu { color: #8b8f9a; }
|
||||
.flow-more { margin-top: 8px; width: 100%; padding: 7px; background: #14171f; color: #c9ccd3; border: 1px solid #2a3142; border-radius: 6px; font-family: inherit; font-size: 12px; cursor: pointer; }
|
||||
.flow-more:hover { background: #1c2030; border-color: #6aa9ff; }
|
||||
.flow-modal { position: fixed; inset: 0; z-index: 1100; display: flex; align-items: center; justify-content: center; padding: 40px 16px; }
|
||||
.flow-modal-ov { position: absolute; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); }
|
||||
.flow-modal-box { position: relative; width: 100%; max-width: 460px; max-height: 80vh; overflow-y: auto; background: #0d1018; border: 1px solid #2f3543; border-radius: 12px; padding: 14px 16px; box-shadow: 0 18px 50px rgba(0,0,0,0.55); }
|
||||
.flow-modal-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 13px; font-weight: 700; color: #f0f0f0; margin: -14px -16px 10px; padding: 12px 16px 8px; position: sticky; top: -14px; background: #0d1018; border-bottom: 1px solid #1f2330; }
|
||||
.flow-modal-x { flex: none; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; padding: 0; background: #1a1d26; color: #e6e9ef; border: 1px solid #2f3543; border-radius: 50%; font-size: 14px; line-height: 1; cursor: pointer; font-family: inherit; }
|
||||
.flow-modal-x:hover { background: #22262f; color: #fff; }
|
||||
.detail dl { display: grid; grid-template-columns: 80px 1fr; gap: 6px 14px; margin: 6px 0 14px; }
|
||||
.row.mode-held .detail dl {
|
||||
grid-template-columns: 80px minmax(0, 1fr) 80px minmax(0, 1fr);
|
||||
@@ -5061,7 +5144,7 @@ dl.pending-detail .pending-line-meta { margin-top: 2px; }
|
||||
}
|
||||
}
|
||||
.detail dt { color: #8b8f9a; font-size: 12px; }
|
||||
.detail dd { margin: 0; color: #d6d6d6; }
|
||||
.detail dd { margin: 0; color: #d6d6d6; min-width: 0; overflow-wrap: anywhere; }
|
||||
/* 워치리스트·관심종목 카드 본문 — 라벨/값 2-col grid (매수가/목표가/손절가/저장일·매입평단).
|
||||
OHLC는 별도 `.ohlc-mini` 블록으로 분리. */
|
||||
.detail .grid2 { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 12px; row-gap: 6px; margin: 6px 0 14px; }
|
||||
@@ -5106,7 +5189,7 @@ dl.pending-detail .pending-line-meta { margin-top: 2px; }
|
||||
.detail .block.chart-svg .chart-hlines .hl-chip-x { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0; background: transparent; color: #e879f9; border: none; border-radius: 50%; cursor: pointer; font-size: 14px; line-height: 1; font-family: inherit; }
|
||||
.detail .block.chart-svg .chart-hlines .hl-chip-x:hover { background: rgba(232,121,249,0.25); }
|
||||
.detail .block.chart-svg .chart-hlines-add { display: flex; gap: 6px; }
|
||||
.detail .block.chart-svg .chart-hlines-add input { flex: 1; min-width: 0; padding: 6px 10px; background: #0a0d12; color: #e6e9ef; border: 1px solid #2a2f3a; border-radius: 6px; font-size: 13px; font-family: inherit; }
|
||||
.detail .block.chart-svg .chart-hlines-add input { flex: 1; min-width: 0; padding: 6px 10px; background: #0a0d12; color: #e6e9ef; border: 1px solid #2a2f3a; border-radius: 6px; font-size: 16px; font-family: inherit; }
|
||||
.detail .block.chart-svg .chart-hlines-add input:focus { outline: none; border-color: #e879f9; }
|
||||
.detail .block.chart-svg .chart-hlines-add .hl-add-btn { padding: 6px 14px; background: rgba(232,121,249,0.14); color: #e879f9; border: 1px solid rgba(232,121,249,0.5); border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 700; font-family: inherit; white-space: nowrap; }
|
||||
.detail .block.chart-svg .chart-hlines-add .hl-add-btn:hover { background: rgba(232,121,249,0.24); }
|
||||
@@ -7760,9 +7843,6 @@ def render_html() -> str:
|
||||
)
|
||||
|
||||
valid_tids_js = ','.join(f"'{tid}'" for tid in valid_tids)
|
||||
# 자동 갱신 동작 탭 — 전체 탭(자산정보·본인·가희·관심·감시). 감시종목도 카운트다운 동작(2026-06-08 관리자 요청).
|
||||
# 시세는 이미 실시간(rt-px)이라 풀패널 자동갱신은 미체결·시세외 데이터 재동기화 목적.
|
||||
auto_refresh_tids_js = ','.join(f"'{tid}'" for tid in valid_tids)
|
||||
|
||||
# 탭 활성 동기화 — URL hash → html[data-tab]. CSS-only 전환이라 클릭은 즉시.
|
||||
# 자산정보 탭으로 진입할 때는 전체 fetch 트리거 — 모든 owner를 한 화면에 모아 보는 요약 탭이라 stale 최소화.
|
||||
@@ -8934,6 +9014,8 @@ def render_html() -> str:
|
||||
# dragging 클래스 해제는 반드시 transform 클리어와 동시에. 분리되면 sticky topbar가 .page transform 좌표계에 갇혀 스크롤 따라옴.
|
||||
'function reset(){c.classList.remove("dragging");c.style.transform="";p.style.transform="";p.style.opacity="";p.classList.remove("committed","spinning");}'
|
||||
'document.addEventListener("touchstart",function(e){'
|
||||
# 종목 상세 팝업 열림 중엔 PTR 비활성 — 팝업 내부 스크롤이 전체 새로고침을 부르지 않게.
|
||||
'if(document.body.classList.contains("detail-open"))return;'
|
||||
'if(window.scrollY<=0&&e.touches.length===1){'
|
||||
'sy=e.touches[0].clientY;dy=0;pull=true;ok=false;'
|
||||
'c.classList.add("dragging");'
|
||||
@@ -8970,66 +9052,67 @@ def render_html() -> str:
|
||||
'})();</script>'
|
||||
)
|
||||
|
||||
# 자동 갱신 — 토글 없이 장 열림 중 항상 3s 주기(백그라운드). 계좌/관심 탭 보일 때만 __behive_load.
|
||||
# 상단 배지는 상태 점 하나만(클릭 불가): 🟢 장중 자동갱신 / ⚪ 장마감. 카운트다운 없음.
|
||||
# 장 닫힘 중엔 갱신 멈추고 저속 폴링으로 시장 열림만 감지.
|
||||
# location.reload() 제거 → 깜빡임·탭 클릭 묻힘 없음. fetch 완료 후에 재무장(arm)해 호출 중첩 방지.
|
||||
# 시장 상태 배지 + 개장/마감 전환 감지 (2026-07-22 이벤트 기반 전환).
|
||||
# ⚠️ 3초 주기 전면 패널 swap 폐지 — 매 3초 <section> innerHTML 통째 교체가 스크롤·열린 UI를
|
||||
# 파괴해 기능마다 "3초에도 유지" 처리를 반복해야 했음. 갱신은 이제 이벤트가 담당:
|
||||
# · 시세·손익·KPI → realtime_script 1초 틱(WS)
|
||||
# · 체결·주문(접수/취소) → state/fill_signal bump → 1초 틱이 감지해 필요할 때만 1회 갱신
|
||||
# 여기 스크립트는 상단 상태 점(🟢장중/⚪장마감)만 유지 + 개장/마감 전환 시 1회 재렌더
|
||||
# (day-change 게이트·시간대 표시 반영). 정상 상태에선 주기적 full swap 없음.
|
||||
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 SEC=3,TICK=1000,t=null,remain=0;'
|
||||
# 장 닫힘 동안 시장 열림 감지용 저속 폴링. visible 일 때만 동작.
|
||||
'var SLOW_MS=60000,slow=null;'
|
||||
'var SLOW_MS=60000,poll=null,cur=MARKET;'
|
||||
'var HSEQ_MS=20000,hpoll=null,hseq=null;' # 외부 매매 감지 폴링(장중만)
|
||||
'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()&&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(){'
|
||||
'stopSlow();'
|
||||
'slow=setInterval(function(){'
|
||||
'if(marketNow()){stopSlow();return;}'
|
||||
# 배지 점: 장중 초록(.live) / 장마감 어두운 회색. 공유 플래그(apply·폴링이 함께 갱신) 우선, 없으면 cur.
|
||||
'function dot(){var m=(typeof window.__behive_market_active!=="undefined")?!!window.__behive_market_active:cur;if(badge)badge.classList.toggle("live",m);}'
|
||||
'function stopPoll(){if(poll){clearInterval(poll);poll=null;}}'
|
||||
# 60초마다 /api/market_state(키움 미경유·시각+휴장일만) 확인 → 배지 갱신 + 개장/마감 전환 시에만 1회 재렌더.
|
||||
'function startPoll(){'
|
||||
'stopPoll();'
|
||||
'poll=setInterval(function(){'
|
||||
'if(document.visibilityState!=="visible")return;'
|
||||
'if(window.__behive_load)window.__behive_load();'
|
||||
'fetch("/api/market_state",{cache:"no-store",credentials:"same-origin"})'
|
||||
'.then(function(r){return r.json();})'
|
||||
'.then(function(s){'
|
||||
'var act=!!(s&&s.active);'
|
||||
'if(act===cur)return;'
|
||||
'cur=act;'
|
||||
# set_market 이 공유 플래그 갱신 + arm(=dot) 호출. 없을 때만 직접 dot.
|
||||
'if(window.__behive_set_market)window.__behive_set_market(act);else dot();'
|
||||
# 전환(장 시작/마감) 시에만 1회 전체 재렌더 — day-change 게이트·시간대 라벨 반영.
|
||||
'if(window.__behive_load)window.__behive_load();'
|
||||
'}).catch(function(){});'
|
||||
'},SLOW_MS);'
|
||||
'}'
|
||||
'function arm(){'
|
||||
'stop();'
|
||||
'if(marketNow())stopSlow();else startSlow();'
|
||||
'dot();'
|
||||
'if(!ok())return;'
|
||||
'remain=SEC;'
|
||||
't=setInterval(function(){'
|
||||
'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;}'
|
||||
# select 드롭다운 열림 상태(=focus)에서도 갱신 미룸 — 사용자가 옵션 고르는 중.
|
||||
'var ae=document.activeElement;'
|
||||
'if(ae&&ae.tagName==="SELECT")return;'
|
||||
# 관심종목 '+' 메뉴 열림 중엔 갱신 미룸 — 패널 swap 으로 메뉴가 닫히는 것 방지.
|
||||
'if(document.querySelector("[data-igrp-menu]:not([hidden])"))return;'
|
||||
'remain-=1;'
|
||||
'if(remain<=0){'
|
||||
'stop();'
|
||||
'var a=active();'
|
||||
'var ow=(a&&a.indexOf("tab-")===0)?a.slice(4):null;'
|
||||
'var p=window.__behive_load?window.__behive_load(ow):Promise.resolve();'
|
||||
'p.then(arm,arm);'
|
||||
'return;'
|
||||
'}'
|
||||
'},TICK);'
|
||||
# 외부 매매(키움 앱 등) 감지 — 장중 20s 간격 /api/holdings_seq 폴링. 보유 시그니처가 바뀌면
|
||||
# (앱 직거래·타 기기 체결 등 fill_signal 안 오는 변화 포함) 패널 1회 fresh 갱신. 첫 관측은 기준만.
|
||||
'function stopHPoll(){if(hpoll){clearInterval(hpoll);hpoll=null;}}'
|
||||
'function startHPoll(){'
|
||||
'stopHPoll();'
|
||||
'hpoll=setInterval(function(){'
|
||||
'if(document.visibilityState!=="visible")return;'
|
||||
'if(window.__behive_market_active===false)return;' # 장외엔 외부 매매 없음 → 키움 콜 아낌
|
||||
'fetch("/api/holdings_seq",{cache:"no-store",credentials:"same-origin"})'
|
||||
'.then(function(r){return r.json();})'
|
||||
'.then(function(d){'
|
||||
'if(!d||!d.seq)return;'
|
||||
'if(hseq===null){hseq=d.seq;return;}' # 첫 관측은 기준값만 저장
|
||||
'if(hseq!==d.seq){'
|
||||
'hseq=d.seq;'
|
||||
'if(window.__behive_load)window.__behive_load(window.__behive_active_owner?window.__behive_active_owner():null,true);'
|
||||
'}'
|
||||
'}).catch(function(){});'
|
||||
'},HSEQ_MS);'
|
||||
'}'
|
||||
# __behive_auto_arm — 수동 새로고침·PTR 완료 후 호출됨. 이제 배지만 재확인(주기 재무장 없음).
|
||||
'function arm(){dot();}'
|
||||
'window.__behive_auto_arm=arm;'
|
||||
'document.addEventListener("visibilitychange",arm);'
|
||||
'addEventListener("hashchange",arm);'
|
||||
'document.addEventListener("visibilitychange",dot);'
|
||||
'if(window.__behive_set_market)window.__behive_set_market(MARKET);'
|
||||
'arm();'
|
||||
'dot();startPoll();startHPoll();'
|
||||
'})();</script>'
|
||||
)
|
||||
|
||||
@@ -10240,6 +10323,131 @@ def render_html() -> str:
|
||||
'});'
|
||||
'}'
|
||||
'},true);'
|
||||
'})();'
|
||||
# 종목 상세 팝업 — <details> open 시 백드롭 + 하단 sticky 액션 바(마지막 항목=닫기 ✕) 표시. 백드롭/✕/ESC 로 닫음.
|
||||
# 표시 전환만 담당(차트·상태 JS 불변). body 스크롤은 detail-open 클래스로 잠금.
|
||||
'(function(){'
|
||||
'var bd=null;'
|
||||
'function closeAll(){'
|
||||
'document.querySelectorAll("details.row[open]").forEach(function(d){d.removeAttribute("open");});'
|
||||
'sync();'
|
||||
'}'
|
||||
'function anyOpen(){return !!document.querySelector("details.row[open]");}'
|
||||
'function ensureBackdrop(){'
|
||||
'if(bd)return;'
|
||||
'bd=document.createElement("div");bd.id="detail-backdrop";bd.hidden=true;'
|
||||
'bd.addEventListener("click",closeAll);'
|
||||
'document.body.appendChild(bd);'
|
||||
'}'
|
||||
# 종목 변경 드롭다운 데이터(보유/관심/감시) — 1회 fetch 후 캐시, 모든 select 에 주입.
|
||||
'var _symHtml=null,_symLoading=false;'
|
||||
'function fillSelects(){'
|
||||
'if(!_symHtml)return;'
|
||||
'document.querySelectorAll("select.detail-stock-switch:not([data-filled])").forEach(function(sel){'
|
||||
'sel.innerHTML=_symHtml;sel.setAttribute("data-filled","1");'
|
||||
'var cur=sel.getAttribute("data-cur")||"";if(cur)sel.value=cur;'
|
||||
'});'
|
||||
'}'
|
||||
'function loadSymbols(){'
|
||||
'if(_symHtml||_symLoading)return;'
|
||||
'_symLoading=true;'
|
||||
'fetch("/api/symbols/all?split_owner=1",{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(dat){'
|
||||
'var items=(dat&&dat.items)||[];'
|
||||
# 보유는 소유자(관리자/가희)별로 분리, 관심·감시는 그대로. 각 그룹의 탭으로 data-tab 지정.
|
||||
'var G=[{lb:"관리자 보유",tab:"tab-self",f:function(it){return it.source==="보유"&&it.owner!=="가희";}},'
|
||||
'{lb:"가희 보유",tab:"tab-gahee",f:function(it){return it.source==="보유"&&it.owner==="가희";}},'
|
||||
'{lb:"관심",tab:"tab-interests",f:function(it){return it.source==="관심";}},'
|
||||
'{lb:"감시",tab:"tab-wl",f:function(it){return it.source==="감시";}}];'
|
||||
'var h=\'<option value="">종목 변경…</option>\';'
|
||||
'G.forEach(function(g){'
|
||||
'var rows=items.filter(g.f);if(!rows.length)return;'
|
||||
'h+=\'<optgroup label="\'+g.lb+\'">\';'
|
||||
'rows.forEach(function(it){'
|
||||
'var nm=(it.name||it.code).replace(/</g,"<").replace(/"/g,""");'
|
||||
'h+=\'<option value="\'+it.code+\'" data-tab="\'+g.tab+\'">\'+nm+\'</option>\';'
|
||||
'});'
|
||||
'h+="</optgroup>";'
|
||||
'});'
|
||||
'_symHtml=h;_symLoading=false;fillSelects();'
|
||||
'}).catch(function(){_symLoading=false;});'
|
||||
'}'
|
||||
# 종목 전환 — 대상 탭으로 바꾼 뒤 해당 행을 열어 팝업 교체. (숨은 탭 행은 display:none 이라 탭 전환 필수)
|
||||
'function openStock(code,tab){'
|
||||
'document.querySelectorAll("details.row[open]").forEach(function(d){d.removeAttribute("open");});'
|
||||
'if(tab){document.documentElement.setAttribute("data-tab",tab);if(window.__behive_set_meta_active)window.__behive_set_meta_active();}'
|
||||
'var rows=document.querySelectorAll(\'details.row[data-row-key="\'+code+\'"],details.row[data-row-key^="\'+code+\':"]\');'
|
||||
'rows.forEach(function(r){r.setAttribute("open","");});'
|
||||
'sync();'
|
||||
'}'
|
||||
# 열린 팝업의 액션 버튼(.actions)을 맨 끝으로 옮겨 sticky footer 로. 버튼 위=종목변경 select, 마지막=닫기.
|
||||
# 원래 DOM 순서상 차트 위에 있어 sticky 만으로는 하단 고정 안 됨 → 위치 이동. .actions 없는 팝업(phantom/pending)엔 생성.
|
||||
'function ensureActionFooters(){'
|
||||
'document.querySelectorAll("details.row[open] > .detail").forEach(function(d){'
|
||||
'var act=d.querySelector(":scope > .actions");'
|
||||
'if(!act){act=document.createElement("div");act.className="actions";d.appendChild(act);}'
|
||||
'if(!act.classList.contains("detail-actions-footer")){act.classList.add("detail-actions-footer");d.appendChild(act);}'
|
||||
# 종목 변경 드롭다운 — 버튼 위(첫 항목)
|
||||
'if(!act.querySelector(":scope > .detail-stock-switch")){'
|
||||
'var sw=document.createElement("select");sw.className="detail-stock-switch";'
|
||||
'var rk=(d.parentNode&&d.parentNode.getAttribute)?(d.parentNode.getAttribute("data-row-key")||""):"";'
|
||||
'sw.setAttribute("data-cur",rk.split(":")[0]);'
|
||||
'sw.innerHTML=\'<option value="">종목 변경…</option>\';'
|
||||
'act.insertBefore(sw,act.firstChild);'
|
||||
'}'
|
||||
# 닫기 버튼 — 마지막 항목 (중복 방지)
|
||||
'if(!act.querySelector(":scope > .btn-detail-close")){'
|
||||
'var x=document.createElement("button");x.type="button";x.className="btn-detail-close";'
|
||||
'x.setAttribute("aria-label","닫기");x.textContent="✕ 닫기";'
|
||||
'x.addEventListener("click",closeAll);'
|
||||
'act.appendChild(x);'
|
||||
'}'
|
||||
'});'
|
||||
'loadSymbols();fillSelects();'
|
||||
'}'
|
||||
# 팝업 상단 sticky 헤더 — 종목명(+소유자 배지)·현재가를 묶어 최상단 고정. 스크롤해도 유지.
|
||||
'function ensureHeaders(){'
|
||||
'document.querySelectorAll("details.row[open] > .detail").forEach(function(d){'
|
||||
'if(d.querySelector(":scope > .detail-header"))return;'
|
||||
'var name=d.querySelector(":scope > .detail-name");'
|
||||
'var simple=d.querySelector(":scope > .detail-simple");'
|
||||
'if(!name&&!simple)return;'
|
||||
'var hdr=document.createElement("div");hdr.className="detail-header";'
|
||||
'd.insertBefore(hdr,d.firstChild);'
|
||||
'if(name)hdr.appendChild(name);'
|
||||
'if(simple)hdr.appendChild(simple);'
|
||||
'});'
|
||||
'}'
|
||||
'function sync(){'
|
||||
'ensureBackdrop();'
|
||||
'var on=anyOpen();'
|
||||
'bd.hidden=!on;'
|
||||
'if(on){ensureHeaders();ensureActionFooters();}'
|
||||
'document.body.classList.toggle("detail-open",on);'
|
||||
'}'
|
||||
# 종목 변경 select 선택 → 전환.
|
||||
'document.addEventListener("change",function(e){'
|
||||
'var sel=e.target;'
|
||||
'if(!sel||!sel.classList||!sel.classList.contains("detail-stock-switch"))return;'
|
||||
'var code=sel.value;if(!code)return;'
|
||||
'var opt=sel.selectedOptions&&sel.selectedOptions[0];'
|
||||
'openStock(code,opt?opt.getAttribute("data-tab"):null);'
|
||||
'});'
|
||||
'document.addEventListener("toggle",function(e){'
|
||||
'var d=e.target;'
|
||||
'if(!d||d.nodeName!=="DETAILS"||!d.classList.contains("row"))return;'
|
||||
'sync();'
|
||||
# 팝업 열릴 때 스크롤 맨 위로 초기화 (이전 스크롤 위치 잔존 방지)
|
||||
'if(d.open){var det=d.querySelector(":scope > .detail");if(det)det.scrollTop=0;}'
|
||||
'},true);'
|
||||
# ESC — 단, 위에 뜬 정식 모달(.modal)이 있으면 그쪽 우선.
|
||||
'document.addEventListener("keydown",function(e){'
|
||||
'if(e.key!=="Escape"||!anyOpen())return;'
|
||||
'if(document.querySelector(".modal:not(.hidden)"))return;'
|
||||
'closeAll();'
|
||||
'});'
|
||||
'document.addEventListener("behive:panels-loaded",sync);'
|
||||
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",sync);'
|
||||
'else sync();'
|
||||
'})();</script>'
|
||||
)
|
||||
|
||||
@@ -11450,7 +11658,7 @@ window.openPinModal = openPinModal;
|
||||
document.querySelectorAll(selByCode(code)).forEach(function(r){
|
||||
r.querySelectorAll('.rt-px').forEach(function(el){ if(el.textContent!==t) el.textContent=t; }); // 값 바뀔 때만 → 깜빡임 방지
|
||||
// 관심·감시 행 등락률 — KRX 기준이라 0B 피드 pct를 그대로 사용(보유행은 NXT 기준이라 paintDay가 따로 처리).
|
||||
var dp=r.querySelector('.rt-wl-daypct'); if(dp){ dp.textContent=pctstr; setUD(dp,q.pct); }
|
||||
r.querySelectorAll('.rt-wl-daypct').forEach(function(dp){ dp.textContent=pctstr; setUD(dp,q.pct); }); // summary+심플정보 팝업 사본 모두 갱신
|
||||
});
|
||||
}
|
||||
function paintVI(code,active){
|
||||
@@ -11486,7 +11694,7 @@ window.openPinModal = openPinModal;
|
||||
var qty=parseFloat(r.getAttribute('data-rt-qty')||'0');
|
||||
if(pc<=0) return;
|
||||
var dc=price-pc, pct=(price/pc-1)*100, dv=dc*qty;
|
||||
var sp=r.querySelector('.rt-daypct'); if(sp){ sp.textContent=(pct>=0?'+':'')+pct.toFixed(2)+'%'; setUD(sp,dc); }
|
||||
r.querySelectorAll('.rt-daypct').forEach(function(sp){ sp.textContent=(pct>=0?'+':'')+pct.toFixed(2)+'%'; setUD(sp,dc); }); // summary+심플정보 팝업 사본 모두 갱신
|
||||
var sv=r.querySelector('.rt-dayvalue'); if(sv){ sv.textContent=sgn(dv)+'원'; setUD(sv,dc); }
|
||||
var sd=r.querySelector('.rt-daychange'); if(sd){ sd.textContent=sgn(dc)+'원 ('+(pct>=0?'+':'')+pct.toFixed(2)+'%)'; setUD(sd,dc); }
|
||||
});
|
||||
@@ -11654,6 +11862,64 @@ window.openPinModal = openPinModal;
|
||||
})();</script>
|
||||
<style>.rt-vi-badge{display:inline-block;margin-left:6px;padding:0 5px;border-radius:4px;font-size:10px;font-weight:700;color:#1a1205;background:#ffd166;vertical-align:middle;cursor:pointer}</style>'''
|
||||
|
||||
# 수급 블록 — 팝업 .flow-box 를 열 때 lazy fetch(/api/investor_flow). 5일 표 + 더보기(20일 오버레이).
|
||||
flow_script = r'''<script>(function(){
|
||||
var cache={};
|
||||
function fmt(n){n=n||0;var s=n>0?'+':(n<0?'−':'');var a=Math.abs(n);var t;
|
||||
if(a>=100000000)t=(a/100000000).toFixed(2)+'억';
|
||||
else if(a>=10000)t=(a/10000).toFixed(1)+'만';
|
||||
else t=a.toLocaleString();
|
||||
return s+t;}
|
||||
function cls(n){return n>0?'up':(n<0?'down':'neu');}
|
||||
function fmtDate(d){d=(d||'').replace(/[^0-9]/g,'');return d.length>=8?(d.slice(4,6)+'/'+d.slice(6,8)):d;}
|
||||
function rowHtml(r){return '<tr><td class="flow-d">'+fmtDate(r.date)+'</td>'+
|
||||
'<td class="flow-n '+cls(r.foreign)+'">'+fmt(r.foreign)+'</td>'+
|
||||
'<td class="flow-n '+cls(r.institution)+'">'+fmt(r.institution)+'</td>'+
|
||||
'<td class="flow-n '+cls(r.individual)+'">'+fmt(r.individual)+'</td></tr>';}
|
||||
function tableHtml(rows){var h='<table class="flow-tbl"><thead><tr><th>날짜</th><th>외국인</th><th>기관</th><th>개인</th></tr></thead><tbody>';
|
||||
rows.forEach(function(r){h+=rowHtml(r);});return h+'</tbody></table>';}
|
||||
function render(box,rows){
|
||||
var code=box.getAttribute('data-flow-code');cache[code]=rows;
|
||||
if(!rows.length){box.innerHTML='<div class="flow-title flow-err">수급 데이터 없음</div>';return;}
|
||||
var h='<div class="flow-title">외국인·기관·개인 수급 <span class="flow-unit">(주, +매수 · 최근 5일)</span></div>';
|
||||
h+=tableHtml(rows.slice(0,5));
|
||||
if(rows.length>5)h+='<button type="button" class="flow-more" data-flow-more="'+code+'">더보기 (20일)</button>';
|
||||
box.innerHTML=h;
|
||||
}
|
||||
function loadOpen(){
|
||||
document.querySelectorAll('details.row[open] .flow-box[data-flow-code]').forEach(function(box){
|
||||
if(box.getAttribute('data-loaded'))return;
|
||||
var code=box.getAttribute('data-flow-code');if(!code)return;
|
||||
box.setAttribute('data-loaded','1');
|
||||
if(cache[code]){render(box,cache[code]);return;}
|
||||
box.innerHTML='<div class="flow-title">수급 불러오는 중…</div>';
|
||||
fetch('/api/investor_flow?code='+encodeURIComponent(code),{credentials:'same-origin'})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){if(d&&d.rows)render(box,d.rows);else box.innerHTML='<div class="flow-title flow-err">수급 데이터 없음</div>';})
|
||||
.catch(function(){box.removeAttribute('data-loaded');box.innerHTML='<div class="flow-title flow-err">수급 조회 실패</div>';});
|
||||
});
|
||||
}
|
||||
function openFlowModal(code,rows){
|
||||
var ov=document.createElement('div');ov.className='flow-modal';
|
||||
ov.innerHTML='<div class="flow-modal-ov"></div><div class="flow-modal-box">'+
|
||||
'<div class="flow-modal-head"><span>외국인·기관·개인 수급 · 20일 <span class="flow-unit">(주, +매수)</span></span>'+
|
||||
'<button type="button" class="flow-modal-x" aria-label="닫기">✕</button></div>'+tableHtml(rows)+'</div>';
|
||||
function close(){document.removeEventListener('keydown',esc,true);ov.remove();}
|
||||
function esc(e){if(e.key==='Escape'){e.stopPropagation();close();}}
|
||||
ov.querySelector('.flow-modal-ov').addEventListener('click',close);
|
||||
ov.querySelector('.flow-modal-x').addEventListener('click',close);
|
||||
document.addEventListener('keydown',esc,true);
|
||||
document.body.appendChild(ov);
|
||||
}
|
||||
document.addEventListener('click',function(e){
|
||||
var b=e.target.closest&&e.target.closest('[data-flow-more]');if(!b)return;
|
||||
var code=b.getAttribute('data-flow-more');var rows=cache[code];if(rows)openFlowModal(code,rows);
|
||||
});
|
||||
document.addEventListener('behive:panels-loaded',loadOpen);
|
||||
document.addEventListener('toggle',function(e){var d=e.target;if(d&&d.nodeName==='DETAILS'&&d.classList&&d.classList.contains('row')&&d.open)loadOpen();},true);
|
||||
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',loadOpen);else loadOpen();
|
||||
})();</script>'''
|
||||
|
||||
return f'''<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
@@ -11731,6 +11997,7 @@ window.openPinModal = openPinModal;
|
||||
{modal_script}
|
||||
{order_modal_script}
|
||||
{stock_name_tip_script}
|
||||
{flow_script}
|
||||
{realtime_script}
|
||||
</body>
|
||||
</html>'''
|
||||
@@ -11748,6 +12015,46 @@ _panels_cache: dict[str, dict] = {} # cache_key('all'|owner) → {'json': bytes
|
||||
_panels_locks: dict[str, threading.Lock] = {}
|
||||
_panels_locks_master = threading.Lock()
|
||||
|
||||
# 외부 매매(키움 앱 등) 감지 — fill_watcher는 웹/orders 경유 주문만 fill_signal을 bump하므로,
|
||||
# 앱에서 직접 매매해 바뀐 보유수량은 이벤트 신호가 없다. 전 계좌 보유 시그니처를 브라우저가 짧게
|
||||
# 폴링(20s)해 값이 바뀌면 패널을 1회 갱신. 서버는 TTL 캐시로 kt00018 콜을 20s당 1회로 제한
|
||||
# (다중 탭·기기 공유). 폐지된 3초 전면 swap 대비 훨씬 가볍고, 브라우저는 장중에만 폴링.
|
||||
_HOLDINGS_SEQ_TTL = 20.0
|
||||
_holdings_seq_cache: dict = {'seq': None, 'expires_at': 0.0}
|
||||
_holdings_seq_lock = threading.Lock()
|
||||
|
||||
# 종목별 외국인·기관·개인 수급 (ka10059) — 팝업 lazy fetch용. code별 TTL 캐시(재열람 시 재조회 방지).
|
||||
_FLOW_TTL = 120.0
|
||||
_flow_cache: dict = {} # code -> (rows, expires_at)
|
||||
_flow_lock = threading.Lock()
|
||||
|
||||
|
||||
def _holdings_seq() -> str:
|
||||
"""전 계좌 보유 (code:qty:당일매수:당일매도) 해시. 외부 매매·체결로 수량이 변하면 값이 바뀐다.
|
||||
당일매수/매도 수량까지 넣어 net 무변동 round-trip도 포착. TTL 캐시로 kt00018 폭주 방지.
|
||||
실패 시 마지막 값(또는 '') 반환 — 폴링이 조용히 재시도."""
|
||||
now = time.time()
|
||||
with _holdings_seq_lock:
|
||||
if _holdings_seq_cache['seq'] is not None and now < _holdings_seq_cache['expires_at']:
|
||||
return _holdings_seq_cache['seq']
|
||||
try:
|
||||
import kiwoom_client as kc
|
||||
import hashlib
|
||||
pos = kc.get_positions_all() # {label: [positions]}
|
||||
parts: list[str] = []
|
||||
for label in sorted(pos):
|
||||
for p in sorted(pos[label], key=lambda x: x.get('code') or ''):
|
||||
parts.append(f"{label}|{p.get('code')}:{p.get('qty')}:{p.get('tdy_buyq')}:{p.get('tdy_sellq')}")
|
||||
seq = hashlib.md5('\n'.join(parts).encode('utf-8')).hexdigest()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
with _holdings_seq_lock:
|
||||
return _holdings_seq_cache['seq'] or ''
|
||||
with _holdings_seq_lock:
|
||||
_holdings_seq_cache['seq'] = seq
|
||||
_holdings_seq_cache['expires_at'] = time.time() + _HOLDINGS_SEQ_TTL
|
||||
return seq
|
||||
|
||||
# 데이터 레이어 캐시 — owner별 raw owner_data + balances 보존.
|
||||
# 자산정보 패널은 owner별 KPI 카드 스택이라 owner_data만으로 재렌더 가능. partial fetch가
|
||||
# 자기 owner 슬라이스를 새로 채울 때 'all' 캐시의 summary 슬라이스와 해당 owner 탭만 in-place로
|
||||
@@ -12017,7 +12324,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# 거래 모달 상단 종목 변경 드롭다운용 — 보유+관심+감시 통합 (dedup by code)
|
||||
try:
|
||||
import kiwoom_client as kc
|
||||
seen: set = set()
|
||||
# split_owner=1: 보유를 소유자(본인/가희)별로 분리(같은 종목도 소유자마다 1건). 팝업 종목변경 드롭다운용.
|
||||
# 기본(0): code 전역 dedup — 계산기·주문모달 등 기존 소비자 호환.
|
||||
_sq = parse_qs(parsed.query)
|
||||
split_owner = (_sq.get('split_owner') or ['0'])[0] == '1'
|
||||
seen: set = set() # 관심/감시 dedup (보유 코드 포함)
|
||||
held_owner_seen: set = set() # split_owner 시 (owner, code)
|
||||
items: list = []
|
||||
# 보유 (4계좌)
|
||||
try:
|
||||
@@ -12026,7 +12338,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
owner = '가희' if label.startswith('가희_') else '본인'
|
||||
for p in positions:
|
||||
code = p.get('code')
|
||||
if not code or code in seen:
|
||||
if not code:
|
||||
continue
|
||||
if split_owner:
|
||||
if (owner, code) in held_owner_seen:
|
||||
continue
|
||||
held_owner_seen.add((owner, code))
|
||||
elif code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
items.append({'code': code, 'name': p.get('name', ''), 'source': '보유', 'owner': owner})
|
||||
@@ -12148,6 +12466,42 @@ class Handler(BaseHTTPRequestHandler):
|
||||
traceback.print_exc()
|
||||
self._send_json(500, {'error': f'market_state failed: {e}'})
|
||||
return
|
||||
if parsed.path == '/api/holdings_seq':
|
||||
# 외부 매매 감지용 보유 시그니처 (TTL 캐시). 브라우저가 장중 20s 폴링 → 변화 시 패널 1회 갱신.
|
||||
try:
|
||||
self._send_json(200, {'seq': _holdings_seq()})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self._send_json(500, {'error': f'holdings_seq failed: {e}'})
|
||||
return
|
||||
if parsed.path == '/api/investor_flow':
|
||||
# 종목별 외국인·기관·개인 일별 순매수 (ka10059, 최신순 20일). 팝업 수급 블록 lazy fetch.
|
||||
qs = parse_qs(parsed.query)
|
||||
code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
|
||||
if not code:
|
||||
self._send_json(400, {'error': 'code required'})
|
||||
return
|
||||
now = time.time()
|
||||
with _flow_lock:
|
||||
hit = _flow_cache.get(code)
|
||||
if hit and now < hit[1]:
|
||||
self._send_json(200, {'rows': hit[0]})
|
||||
return
|
||||
try:
|
||||
import kiwoom_client as kc
|
||||
raw = kc.get_investor_flow(code, days=20) or []
|
||||
# get_investor_flow 는 천주 단위(unit_tp=1000) → 주 단위로 환산해 반환.
|
||||
rows = [{'date': r.get('date'),
|
||||
'foreign': (r.get('foreign') or 0) * 1000,
|
||||
'institution': (r.get('institution') or 0) * 1000,
|
||||
'individual': (r.get('individual') or 0) * 1000} for r in raw]
|
||||
with _flow_lock:
|
||||
_flow_cache[code] = (rows, time.time() + _FLOW_TTL)
|
||||
self._send_json(200, {'rows': rows})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self._send_json(500, {'error': f'investor_flow failed: {e}'})
|
||||
return
|
||||
if parsed.path == '/api/quote_book':
|
||||
# 거래 모달 호가창용. ka10004(호가 10단계+잔량) + ka10001(현재가·등락) 묶음.
|
||||
# 1초 polling 가정 — visibility 가드는 클라이언트 측 책임.
|
||||
@@ -12872,6 +13226,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
traceback.print_exc()
|
||||
self._send_json(500, {'ok': False, 'error': f'propose failed: {e}'})
|
||||
return
|
||||
if res.get('ok'):
|
||||
_bump_panel_signal() # PIN 대기 배지(매수예정/매도예정) 즉시 반영
|
||||
status = 200 if res.get('ok') else 400
|
||||
self._send_json(status, res)
|
||||
return
|
||||
@@ -12936,6 +13292,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
traceback.print_exc()
|
||||
self._send_json(500, {'ok': False, 'error': f'propose_multi failed: {e}'})
|
||||
return
|
||||
if res.get('ok'):
|
||||
_bump_panel_signal() # PIN 대기 배지 즉시 반영
|
||||
self._send_json(200 if res.get('ok') else 400, res)
|
||||
return
|
||||
|
||||
@@ -12959,6 +13317,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
traceback.print_exc()
|
||||
self._send_json(500, {'ok': False, 'error': f'verify failed: {e}'})
|
||||
return
|
||||
if res.get('ok'):
|
||||
_bump_panel_signal() # 주문 접수 → 미체결 배지(매수대기/매도대기) 반영 (체결은 fill_watcher가 별도 bump)
|
||||
status = 200 if res.get('ok') else 400
|
||||
self._send_json(status, res)
|
||||
return
|
||||
@@ -12983,6 +13343,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
traceback.print_exc()
|
||||
self._send_json(500, {'ok': False, 'error': f'open order cancel failed: {e}'})
|
||||
return
|
||||
if res.get('ok'):
|
||||
_bump_panel_signal() # 미체결 취소 → 배지 제거 반영
|
||||
self._send_json(200 if res.get('ok') else 400, res)
|
||||
return
|
||||
if self.path == '/api/order/cancel':
|
||||
@@ -12991,6 +13353,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
from orders import handler
|
||||
# cancel_active_card 반환은 dict {'ok','message'} — 활성 카드 없어도 베스트에포트라 200.
|
||||
res = handler.cancel_active_card()
|
||||
if res.get('ok'):
|
||||
_bump_panel_signal() # 실제 활성 카드 취소 시만 → PIN 대기 배지 제거 (카드 없이 닫으면 reload 안 함)
|
||||
self._send_json(200, {'ok': True, 'message': res.get('message') or '카드 취소됨'})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
|
||||
Reference in New Issue
Block a user