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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-08-07 02:00:02 +09:00
parent 307f7844c4
commit f38a3878b1
175 changed files with 3594 additions and 2983 deletions
+264 -26
View File
@@ -2289,26 +2289,46 @@ def _set_surge_toggle(code: str, name: str, on: bool) -> bool:
return on
def _surge_toggle_button_html(code: str, name: str) -> str:
"""detail-name 줄(종목명 옆, `+ 태그` 왼쪽)의 급등락 알림 토글. 켜짐이면 🔔 + has-surge 강조.
def _surge_indicator_html(code: str) -> str:
"""detail-name 줄(종목명 옆)의 급등락 알림 on/off **표시**. 조작 불가 — 켜고 끄기는 알림 팝업에서.
문턱은 종목 ATR14 × K surge_monitor 계산한다 (고정 % 아님) 여기선 on/off만.
아이콘 하나뿐이라(관리자님 요청, 2026-08-04) 무슨 버튼인지는 title 툴팁이 유일한 설명이다.
문턱은 종목 과거 이탈폭의 분위수(p90/p98) surge_monitor 계산한다 (고정 %, ATR 배수도 아님).
2026-08-06 관리자님 지시로 토글 기능을 뺐다. `data-surge-toggle` 없어야 위임 핸들러가
잡지 않는다 다시 버튼으로 만들지 (아이콘 하나뿐이라 오조작 위험이 컸다).
"""
code_s = (code or '').strip()
if not code_s:
return ''
name_s = (name or '').strip()
on = _is_surge_on(code_s)
cls = 'btn-surge has-surge' if on else 'btn-surge'
label = '🔔' if on else '🔕'
title = '급등락 알림 켜짐 — 누르면 끕니다' if on else '급등락 알림 꺼짐 — 누르면 켭니다'
cls = 'surge-ind has-surge' if on else 'surge-ind'
return (
f'<button type="button" class="{cls}" data-surge-toggle="1"'
f'<span class="{cls}" data-surge-ind="1"'
f' data-surge-code="{html.escape(code_s, quote=True)}"'
f' data-surge-name="{html.escape(name_s, quote=True)}"'
f' data-surge-on="{"1" if on else "0"}"'
f' aria-label="급등락 알림" title="{title}">{label}</button>'
f' aria-label="급등락 알림 {"켜짐" if on else "꺼짐"}"'
f' title="급등락 알림 {"켜짐" if on else "꺼짐"}">{"🔔" if on else "🔕"}</span>'
)
def _surge_info_button_html(code: str, name: str) -> str:
"""detail actions 의 '알림' 버튼 — kpi-modal 에 /api/surge 본문을 띄운다(구간 + 오늘 소진 + 토글).
켜진 종목은 has-surge 강조 (메모 버튼의 has-note 같은 패턴).
꺼진 종목에도 노출한다 켜는 입구가 팝업뿐이라서. 종목코드 없으면 미노출
(모니터가 코드로 시세를 조회하므로 `_set_surge_toggle` ValueError).
"""
code_s = (code or '').strip()
if not code_s:
return ''
on = _is_surge_on(code_s)
cls = 'kpi-more-btn btn-surge-info' + (' has-surge' if on else '')
# ⚠️ name 을 함께 넘긴다 — 꺼진 종목은 토글 저장소에 이름이 없어서, 팝업에서 켜면
# 빈 이름으로 저장되고 알림에 종목코드가 찍힌다(2026-08-06 실측).
src = html.escape(f'/api/surge?code={_urlq(code_s)}&name={_urlq((name or "").strip())}', quote=True)
title = html.escape(f'{(name or "").strip()} 알림'.strip(), quote=True)
return (
f'<button type="button" class="{cls}" data-kpi-src="{src}" data-kpi-title="{title}"'
f' data-surge-code="{html.escape(code_s, quote=True)}"'
f' title="급등락 알림 설정·구간">🔔 알림</button>'
)
@@ -3022,7 +3042,8 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
# 관심종목은 그룹 버튼도 +태그 옆에 (actions 아님).
group_add_btn = _interest_group_button_html(c.get('stock') or '') if source == 'interests' else ''
# 급등락 알림 토글은 +태그 왼쪽 (관리자님 지정 위치).
surge_btn = _surge_toggle_button_html(c.get('code') or '', c.get('stock') or '')
surge_btn = _surge_indicator_html(c.get('code') or '')
surge_info_btn = _surge_info_button_html(c.get('code') or '', c.get('stock') or '')
detail_name_html = ''
if not is_pending:
code_span = f'<span class="detail-code">{code}</span>' if code else ''
@@ -3061,6 +3082,7 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
actions_html = (
'<div class="actions">'
f'{note_btn}'
f'{surge_info_btn}'
f'{analyze_btn}'
f'{info_btn}'
f'{trade_btn}'
@@ -3083,6 +3105,7 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
actions_html = (
'<div class="actions">'
f'{wl_note_btn}'
f'{surge_info_btn}'
f'{analyze_btn}'
f'{wl_info_btn}'
f'{trade_btn}'
@@ -3328,6 +3351,160 @@ def _render_cashflow_body(owner: str) -> str:
)
# 분위수·상하한가 트리거 키 → 사람이 읽는 이름. VI 는 발동시각이 키에 붙는다(`vi:105814`).
_SURGE_KEY_NAMES = {
'limit_up': '상한가', 'up2': '급등 확대', 'up': '급등 1차',
'down': '급락 1차', 'down2': '급락 확대', 'limit_down': '하한가',
}
def _surge_key_label(key: str) -> str:
if key.startswith('vi:'):
t = key[3:]
return f'VI {t[:2]}:{t[2:4]}' if len(t) >= 4 and t.isdigit() else 'VI'
return _SURGE_KEY_NAMES.get(key, key)
def _render_surge_body(code: str, name_hint: str = '') -> str:
"""급등락 알림 팝업 본문 — on/off 토글 + 알림 구간 + 오늘 소진 현황.
**키움 0.** surge_monitor 캐시 파일을 읽기만 한다. `sm.get_threshold_map()` /
`sm.get_limit_map()` 각각 ka10081(유량 초당 5)·ka10095 때리므로 절대 부르지 않는다
팝업을 때마다 조회가 나가고, 문턱 산출은 사이클당 12종목 페이싱이 있어 여기서 부르면
설계가 무너진다. 캐시에 없으면 계산 대기로 표시한다.
"""
import surge_monitor as sm
code_s = (code or '').strip()
if not code_s:
return '<div class="muted small">종목코드가 없어 알림을 설정할 수 없어요.</div>'
on = _is_surge_on(code_s)
# 켜진 종목은 저장된 이름, 꺼진 종목은 호출측이 넘긴 이름 — 후자가 없으면 켤 때 이름이 빈 채로 저장된다.
name = ((_load_surge_toggles().get('by_code', {}).get(code_s) or {}).get('name') or '').strip()
name = name or (name_hint or '').strip()
today = datetime.now(KST).strftime('%Y-%m-%d')
thr_cache = sm.load_json(sm.THR_CACHE, {})
lim_cache = sm.load_json(sm.LIMITS_CACHE, {})
thr_fresh = thr_cache.get('date') == today
thr_raw = (thr_cache.get('by_code') or {}) if thr_fresh else {}
lim_raw = (lim_cache.get('by_code') or {}) if lim_cache.get('date') == today else {}
thr = thr_raw.get(code_s) # None=미산출, {}=이력부족(확정), dict=정상
lim = lim_raw.get(code_s) or {}
q = None
try:
if _RT_HUB is not None:
q = _RT_HUB.get_quote(code_s)
except Exception:
q = None
# ── 토글 (기존 [data-surge-toggle] 위임 핸들러가 그대로 잡는다) ──
tog = (
f'<button type="button" class="sg-toggle{" has-surge" if on else ""}" data-surge-toggle="1"'
f' data-surge-code="{html.escape(code_s, quote=True)}"'
f' data-surge-name="{html.escape(name, quote=True)}"'
f' data-surge-on="{"1" if on else "0"}"'
f' data-surge-refresh="/api/surge?code={_urlq(code_s)}&amp;name={_urlq(name)}">'
f'{"🔔 알림 켜짐 — 누르면 끕니다" if on else "🔕 알림 꺼짐 — 누르면 켭니다"}</button>'
)
if not on:
return (
f'<div class="sg-body">{tog}'
f'<div class="muted small sg-note">감시 중이 아니라 알림 구간이 산출되지 않았어요. '
f'켜면 다음 감시 사이클(평일 09:00~15:35, 1분 간격)에 이 종목 과거 이력으로 구간을 계산합니다.</div>'
f'</div>'
)
# ── 헤더: 전일종가 · 현재가 ──
prev_close = (thr or {}).get('prev_close') or 0
head = []
if prev_close:
head.append(f'<div class="pm-srow"><span>전일종가</span><b>{prev_close:,}원</b></div>')
if q and q.get('price'):
pct = q.get('pct')
pcls = 'up' if (pct or 0) > 0 else ('down' if (pct or 0) < 0 else '')
pstr = f' <span class="{pcls}">({pct:+.2f}%)</span>' if pct is not None else ''
head.append(f'<div class="pm-srow"><span>현재가</span><b>{int(q["price"]):,}{pstr}</b></div>')
# ── 구간 사다리 (호가창처럼 위가 비쌈) ──
def band(label: str, price: int, pct: float | None, cls: str) -> str:
p = f'<span class="sg-p">{pct:+.1f}%</span>' if pct is not None else '<span class="sg-p"></span>'
return (f'<div class="sg-band {cls}"><span class="sg-k">{html.escape(label)}</span>'
f'<b>{price:,}</b>{p}</div>')
bands: list[str] = []
if lim.get('upl'):
bands.append(band('⏫ 상한가', lim['upl'], (lim['upl'] / prev_close - 1) * 100 if prev_close else None, 'lim up'))
if thr:
t1, t2 = thr['thr'], thr.get('thr_big') or 0
if t2:
bands.append(band('급등 확대', round(prev_close * (1 + t2 / 100)), t2, 'up'))
bands.append(band('급등 1차', round(prev_close * (1 + t1 / 100)), t1, 'up'))
bands.append(band('전일종가', prev_close, None, 'mid'))
bands.append(band('급락 1차', round(prev_close * (1 - t1 / 100)), -t1, 'down'))
if t2:
bands.append(band('급락 확대', round(prev_close * (1 - t2 / 100)), -t2, 'down'))
if lim.get('lst'):
bands.append(band('⏬ 하한가', lim['lst'], (lim['lst'] / prev_close - 1) * 100 if prev_close else None, 'lim down'))
notes: list[str] = []
if thr == {}:
notes.append(f'이력 {sm.MIN_HISTORY}일 미만이라 문턱을 못 냅니다 — VI·상하한가만 감시해요.')
elif not thr_fresh and thr_cache.get('date'):
# 전일종가가 바뀌면 구간이 통째로 달라진다 → 어제 값을 '알림 구간'으로 보여주지 않는다.
notes.append(f'⚠️ 마지막 산출이 {html.escape(str(thr_cache.get("date")))} 기준이라 표시하지 않아요 '
f'— 전일종가가 바뀌면 구간도 전부 달라집니다. 장 시작 후 갱신됩니다.')
elif thr is None:
# 감시 사이클과 같은 판정을 쓴다(평일 09:00~15:35 · 휴장일 제외).
notes.append('구간 계산 대기 중이에요 — 사이클당 12종목씩 채워서 몇 분 안에 나옵니다.'
if sm.is_market_hours() else '다음 장 시작 후에 계산됩니다.')
band_html = (f'<div class="pm-listhead">알림 구간</div><div class="sg-bands">{"".join(bands)}</div>'
if bands else '')
# ── 오늘 소진 현황 ──
day = (sm.load_json(sm.ALERTS_STATE, {}).get(today) or {})
fired = [k for k in (day.get(code_s) or []) if isinstance(k, str)]
pend = list(((day.get(sm.PENDING_KEY) or {}).get(code_s) or {}))
order = list(_SURGE_KEY_NAMES)
left = [k for k in order if k not in fired]
empty = '<span class="muted small">없음</span>'
def chips(keys: list, cls: str, blank: str = empty) -> str:
if not keys:
return blank
return ''.join(f'<span class="sg-chip {cls}">{html.escape(_surge_key_label(k))}</span>' for k in keys)
def trow(label: str, inner: str) -> str:
return f'<div class="sg-trow"><span>{label}</span><div class="sg-chips">{inner}</div></div>'
today_rows = [
trow('나간 알림', chips(fired, 'fired')),
trow('남은 알림', chips(left, 'left', '<span class="muted small">모두 소진</span>')),
]
if pend:
today_rows.append(trow('보류 중', chips(pend, 'pend')))
foot = []
if thr:
top1 = round((1 - sm.SURGE_PCTL) * 100)
top2 = round((1 - sm.SURGE_PCTL_BIG) * 100)
foot.append(f'{thr.get("n", 0)}일 이력 기준 · 상위 {top1}%(1차) / {top2}%(확대)')
foot.append('VI 발동은 구간과 별개로, 발동할 때마다 알림 (하루 상한 없음)')
head_html = '<div class="pm-sum">' + ''.join(head) + '</div>' if head else ''
notes_html = ''.join(f'<div class="muted small sg-note">{n}</div>' for n in notes)
return (
'<div class="sg-body">' + tog + head_html + band_html + notes_html
+ '<div class="pm-listhead">오늘 알림</div>' + ''.join(today_rows)
+ '<div class="sg-foot">' + '<br>'.join(foot) + '</div>'
+ '</div>'
)
def _render_kpi_modal() -> str:
"""KPI 상세 공용 팝업 (투자원금·당일 입출금 등). shell HTML 직속이라 panels swap 영향 없음.
라벨 [!] 클릭 버튼의 data-kpi-src fetch 본문 innerHTML."""
@@ -4196,12 +4373,14 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
row_key = (code or stock) + key_suffix
tag_add_btn = _tag_add_button_html(r.get('code') or '', r.get('stock') or '')
# 급등락 알림 토글 — +태그 왼쪽 (관심·감시 행과 같은 위치).
surge_btn = _surge_toggle_button_html(r.get('code') or '', r.get('stock') or '')
# 급등락 알림 on/off 표시 — +태그 왼쪽 (관심·감시 행과 같은 위치). 조작은 actions 의 알림 버튼에서.
surge_btn = _surge_indicator_html(r.get('code') or '')
surge_info_btn = _surge_info_button_html(r.get('code') or '', r.get('stock') or '')
note_btn = _note_button_html(r.get('code') or '', r.get('stock') or '')
trade_btn = (
f'<div class="actions">'
f'{note_btn}'
f'{surge_info_btn}'
f'{_info_button_html(r.get("code") or "", r.get("stock") or "")}'
f'<button type="button" class="btn-trades" data-trade-code="{code}" data-trade-stock="{stock}">거래내역</button>'
f'<button type="button" class="btn-order" data-order-code="{code}" data-order-stock="{html.escape(stock, quote=True)}" data-order-side="BUY" title="매수/매도">💰 거래</button>'
@@ -5962,12 +6141,17 @@ details.row[open] > .detail {
.actions .btn-note:hover { background: rgba(201,178,125,0.14); border-color: #c9b27d; }
.actions .btn-note:active { transform: translateY(1px); }
.actions .btn-note.has-note { color: #f0c95a; border-color: rgba(240,201,90,0.5); background: rgba(240,201,90,0.12); }
/* 급등락 알림 토글 detail-name (종목명 , +태그 왼쪽). 아이콘 하나뿐이라 테두리 없이 둔다.
꺼짐은 흐리게(opacity), 켜짐은 선명 + 초록 배경으로 한눈에 구분. 터치 타깃은 24px 확보. */
.detail-name .btn-surge { align-self: center; flex: none; display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 6px; background: transparent; font-size: 13px; line-height: 1; cursor: pointer; font-family: inherit; opacity: 0.45; filter: grayscale(1); transition: opacity .12s, background .12s; }
.detail-name .btn-surge:hover { opacity: 0.8; background: rgba(143,168,201,0.12); }
.detail-name .btn-surge:active { transform: translateY(1px); }
.detail-name .btn-surge.has-surge { opacity: 1; filter: none; background: rgba(111,211,163,0.16); }
/* 급등락 알림 on/off 표시 detail-name (종목명 , +태그 왼쪽). **조작 불가**(켜고 끄기는 알림 팝업).
꺼짐은 흐리게(opacity), 켜짐은 선명 + 초록 배경으로 한눈에 구분.
cursor/hover/active 주지 않는다 누를 있어 보이면 된다. */
.detail-name .surge-ind { align-self: center; flex: none; display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 6px; background: transparent; font-size: 13px; line-height: 1; opacity: 0.45; filter: grayscale(1); }
.detail-name .surge-ind.has-surge { opacity: 1; filter: none; background: rgba(111,211,163,0.16); }
/* actions '🔔 알림' 버튼 kpi-more-btn 재사용하되(위임 핸들러가 클래스를 잡는다)
생김새는 .actions button 계열로. 켜짐이면 메모의 has-note 같은 방식으로 강조. */
.actions .btn-surge-info { padding: 5px 10px; border-radius: 8px; border: 1px solid rgba(143,168,201,0.3); background: transparent; color: #b0b4be; font-size: 11px; font-weight: 600; line-height: 1.4; width: auto; height: auto; cursor: pointer; font-family: inherit; transition: background .12s, border-color .12s, color .12s; }
.actions .btn-surge-info:hover { background: rgba(143,168,201,0.12); }
.actions .btn-surge-info:active { transform: translateY(1px); }
.actions .btn-surge-info.has-surge { color: #6fd3a3; border-color: rgba(111,211,163,0.5); background: rgba(111,211,163,0.12); }
.note-head-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
.note-del-wrap { position: relative; display: inline-flex; }
.note-confirm { position: absolute; top: calc(100% + 6px); left: 0; display: flex; align-items: center; gap: 6px; background: #1b1f2a; border: 1px solid #3a2030; border-radius: 8px; padding: 6px 8px; box-shadow: 0 6px 18px rgba(0,0,0,0.4); white-space: nowrap; z-index: 5; }
@@ -6180,6 +6364,32 @@ section.tab-content { min-height: calc(100dvh - 126px); }
.owner-card table.kpi th .kpi-more-btn:hover { color: #d6b34a; border-color: rgba(214,179,74,0.55); }
.owner-card table.kpi th .kpi-more-btn:active { transform: translateY(1px); }
/* 급등락 알림 팝업 */
.sg-body { font-variant-numeric: tabular-nums; display: flex; flex-direction: column; gap: 10px; }
.sg-toggle { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid #2a3142; background: #11141c; color: #b0b4be; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
.sg-toggle:hover { background: #1c2030; }
.sg-toggle:active { transform: translateY(1px); }
.sg-toggle.has-surge { color: #6fd3a3; border-color: rgba(111,211,163,0.5); background: rgba(111,211,163,0.12); }
.sg-bands { display: flex; flex-direction: column; gap: 2px; }
.sg-band { display: grid; grid-template-columns: 1fr auto 56px; align-items: baseline; gap: 10px; padding: 6px 10px; border-radius: 6px; font-size: 12px; background: #11141c; }
.sg-band .sg-k { color: #8b90a0; }
.sg-band b { font-size: 13px; }
.sg-band .sg-p { text-align: right; font-size: 11px; opacity: 0.7; }
.sg-band.up b, .sg-band.up .sg-p { color: #ff6b63; }
.sg-band.down b, .sg-band.down .sg-p { color: #74acff; }
.sg-band.lim { background: rgba(224,168,60,0.1); }
.sg-band.lim .sg-k { color: #e0a83c; }
.sg-band.mid { background: transparent; border-top: 1px dashed #2a3142; border-bottom: 1px dashed #2a3142; border-radius: 0; }
.sg-band.mid b { color: #b0b4be; }
.sg-trow { display: grid; grid-template-columns: 68px 1fr; gap: 10px; align-items: baseline; font-size: 12px; }
.sg-trow > span { color: #8b90a0; }
.sg-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.sg-chip { padding: 2px 7px; border-radius: 10px; font-size: 11px; border: 1px solid #2a3142; color: #8b90a0; }
.sg-chip.fired { color: #e0a83c; border-color: rgba(224,168,60,0.45); background: rgba(224,168,60,0.1); }
.sg-chip.pend { color: #ff6b63; border-color: rgba(255,107,99,0.45); border-style: dashed; }
.sg-note { line-height: 1.6; }
.sg-foot { font-size: 11px; color: #6f7484; line-height: 1.7; border-top: 1px solid #1a1e29; padding-top: 8px; }
/* 투자원금 팝업 */
.principal-body { font-variant-numeric: tabular-nums; }
.pm-sum { padding: 10px 12px; background: #11141c; border: 1px solid #1f2330; border-radius: 8px; }
@@ -11023,14 +11233,20 @@ def render_html() -> str:
'if(curLeader)b.classList.add("active");else b.classList.remove("active");'
'});'
'}'
# 급등락 토글 아이콘 즉시 반영. 같은 종목이 여러 패널에 중복 노출되므로 code로 전 인스턴스를 칠한다.
# 급등락 알림 상태 즉시 반영. 같은 종목이 여러 패널에 중복 노출되므로 code로 전 인스턴스를 칠한다.
# 칠할 곳 3종: ①종목명 옆 표시자(span, 조작 불가) ②actions 의 '🔔 알림' 버튼 강조 ③팝업 안 토글.
'function paintSurge(code,on){'
'if(!code)return;'
'document.querySelectorAll(\'[data-surge-toggle][data-surge-code="\'+code+\'"]\').forEach(function(b){'
'b.dataset.surgeOn=on?"1":"0";'
'document.querySelectorAll(\'[data-surge-code="\'+code+\'"]\').forEach(function(b){'
'b.classList.toggle("has-surge",!!on);'
'b.textContent=on?"\\uD83D\\uDD14":"\\uD83D\\uDD15";'
'b.title=on?"급등락 알림 켜짐 — 누르면 끕니다":"급등락 알림 꺼짐 — 누르면 켭니다";'
'if(b.hasAttribute("data-surge-ind")){'
'b.textContent=on?"\\uD83D\\uDD14":"\\uD83D\\uDD15";'
'b.title="급등락 알림 "+(on?"켜짐":"꺼짐");'
'b.setAttribute("aria-label","급등락 알림 "+(on?"켜짐":"꺼짐"));'
'}else if(b.hasAttribute("data-surge-toggle")){'
'b.dataset.surgeOn=on?"1":"0";'
'b.textContent=on?"\\uD83D\\uDD14 알림 켜짐 — 누르면 끕니다":"\\uD83D\\uDD15 알림 꺼짐 — 누르면 켭니다";'
'}'
'});'
'}'
'function populateNoteForm(trigger){'
@@ -11262,6 +11478,13 @@ def render_html() -> str:
'if(j&&j.ok){'
'paintSurge(sCode,!!j.on);'
'showToast((sName||"종목")+" 급등락 알림 "+(j.on?"켰습니다":"껐습니다"));'
# 팝업 안에서 토글한 경우 본문(구간·안내문)이 상태와 어긋나므로 다시 받아온다.
'var rsrc=surgeBtn.getAttribute("data-surge-refresh");'
'var kb=document.querySelector("[data-kpi-body]");'
'if(rsrc&&kb){'
'fetch(rsrc,{credentials:"same-origin"}).then(function(r){return r.json();})'
'.then(function(d){if(d&&d.html)kb.innerHTML=d.html;}).catch(function(){});'
'}'
'}else{'
'paintSurge(sCode,wasOn);'
'showToast("급등락 알림 변경 실패: "+((j&&j.error)||"오류"));'
@@ -13753,6 +13976,21 @@ class Handler(BaseHTTPRequestHandler):
self._send_json(200, {'connected': connected, 'sub_count': sub_count, 'quotes': quotes, 'vi': vi, 'fill_seq': fill_seq})
return
# KPI 라벨 [!] 팝업 본문 — 서버가 HTML 조립, 클라이언트는 innerHTML만.
if parsed.path == '/api/surge':
# 급등락 알림 팝업 본문 (구간 + 오늘 소진 + 토글). 캐시 읽기뿐이라 키움 콜 0.
qs = parse_qs(parsed.query)
code_q = (qs.get('code') or [''])[0].strip()
name_q = (qs.get('name') or [''])[0].strip()
if not code_q:
self._send_json(400, {'error': 'code required'})
return
try:
self._send_json(200, {'html': _render_surge_body(code_q, name_q)})
except Exception as e:
sys.stderr.write(f'/api/surge failed: {e}\n')
self._send_json(500, {'error': str(e)})
return
if parsed.path in ('/api/principal', '/api/cashflow', '/api/deposit'):
qs = parse_qs(parsed.query)
owner_q = (qs.get('owner') or [''])[0].strip()
@@ -838,6 +838,11 @@ def _parse_watchlist_rows(resp: dict) -> dict[str, dict]:
'high': abs(_to_int(row.get('high_pric') or '0')),
'low': abs(_to_int(row.get('low_pric') or '0')),
'volume': _to_int(row.get('trde_qty') or '0'),
# 거래소가 계산한 가격제한폭. 기준가(전일종가)로 정해져 장중 불변.
# 제한폭은 기준가의 호가단위로 절사한 뒤 ±하는 규칙이라 직접 계산하면
# ETF·가격대별 호가단위 표를 재현해야 해 틀릴 여지가 있다 → 응답값 그대로 쓴다.
'upper_limit': abs(_to_int(row.get('upl_pric') or '0')),
'lower_limit': abs(_to_int(row.get('lst_pric') or '0')),
}
return out
+191 -55
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""토글 켜진 종목의 급등락 감시 → 레이 텔레그램 알림.
LLM을 깨우지 않음. 기준을 병행한다:
1. 거래소 VI 발동 "이건 급등락이다" 거래소가 공식 판정. 문턱을 우리가 정할 필요가 .
2. 자기 이력 분위수 장중 이탈폭(전일종가 대비) 종목 과거 이탈폭의 p90 넘으면 알림.
LLM을 깨우지 않음. 기준을 병행한다:
1. 거래소 ·하한가 도달 그날 있는 끝까지 . 가장 강한 신호라 무엇에도 가리지 않는.
2. 거래소 VI 발동 "이건 급등락이다" 거래소가 공식 판정. 문턱을 우리가 정할 필요가 없다.
3. 자기 이력 분위수 장중 이탈폭(전일종가 대비) 종목 과거 이탈폭의 p90 넘으면 알림.
고정 % 쓰지 않는 이유 (실측 관심·감시 65종목 × 280거래일, 2026-08-04):
- 장중 이탈폭 중간값이 3.8% ±3% 급등락이 아니라 평범한
@@ -23,10 +24,16 @@ VI(1h)는 시장 전역 broadcast라 구독이 필요 없고, 현재가(0B)는
_rt_gather_codes() 이미 보유+관심+감시를 구독하므로 토글 종목은 부분집합이다.
구독 상한에 걸려 빠진 종목만 ka10095 배치 1콜로 폴백한다.
·하한가·VI 걸려 있는 동안 분위수 트리거는 **보류**했다가 풀린 뒤에 내보낸다
(2026-08-06 관리자님 요청). 그전엔 조용히 버려져서, VI 중에 문턱을 넘었다가 해제 시점에
되밀린 움직임은 알림이 아예 없었다. 같은 사건을 알리지 않으면서 사실은 잃지 않기 위함.
state:
behive_surge_toggles.json 감시 대상 (behive_web 🔔 토글이 )
surge_thresholds.json {date, by_code: {code: {thr, thr_big, prev_close, n}}}. 하루 1회만 계산
surge_alerts.json {date: {code: [트리거키]}}. 방향별 1 + 확대단계 1
surge_limits.json {date, by_code: {code: {upl, lst}}}. 거래소 ·하한가, 하루 1
surge_alerts.json {date: {code: [트리거키], __pending__: {code: {: 보류정보}}}}
방향별 1 + 확대단계 1 + ·하한가 1, VI 발동 건마다 1
Usage:
python3 surge_monitor.py check # 1회 감시 (launchd 용)
@@ -55,7 +62,10 @@ import kiwoom_client as kc # noqa: E402
STATE_DIR = WORKSPACE / 'state'
TOGGLES = STATE_DIR / 'behive_surge_toggles.json'
THR_CACHE = STATE_DIR / 'surge_thresholds.json'
LIMITS_CACHE = STATE_DIR / 'surge_limits.json'
ALERTS_STATE = STATE_DIR / 'surge_alerts.json'
# 보류 트리거를 담는 예약 키. 종목코드는 6자리라 이 이름과 충돌하지 않는다.
PENDING_KEY = '__pending__'
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
TELEGRAM_ACCOUNT = 'stock' # 레이 봇
@@ -234,7 +244,7 @@ def _compute_thresholds(code: str) -> tuple[dict | None, bool]:
continue
exc.append(max(abs(candles[i]['high'] - pc), abs(candles[i]['low'] - pc)) / pc * 100)
if len(exc) < MIN_HISTORY:
return None
return None, False
exc.sort()
prev_close = candles[-1]['close']
if not prev_close:
@@ -278,38 +288,108 @@ def get_threshold_map(codes: list[str]) -> dict[str, dict]:
return {c: v for c, v in by_code.items() if v.get('thr')}
# ---------------- 판정 ----------------
def evaluate(pct: float, thr: dict | None, vi: dict | None) -> list[tuple[str, str]]:
"""충족된 (트리거키, 사유라벨) 목록.
# ---------------- 상·하한가 (거래소 계산값) ----------------
def get_limit_map(codes: list[str]) -> dict[str, dict]:
"""{code: {'upl', 'lst'}} — 거래소가 계산한 가격제한폭. 하루 1콜(ka10095 배치).
VI 발동 중이면 분위수 트리거는 생략한다 같은 사건을 알리지 않기 위해서.
기준가(전일종가) 정해져 장중 불변이라 하루 1회면 충분하다. 분위수 문턱과 달리
일봉 이력이 필요 없어서 신규상장 이력 부족 종목도 기준으론 감시된다.
배치 호출 자체가 실패하면 캐시하지 않는다 문턱 캐시와 같은 이유로, 실패를
'제한가 없음'으로 굳히면 종목이 하루 내내 조용히 빠진다. 응답에 값이 없는
종목만 dict 굳혀 사이클 재조회를 막는다.
"""
out: list[tuple[str, str]] = []
if vi and vi.get('active'):
t = (vi.get('trigger_time') or '').strip() or 'na'
kind = (vi.get('apply_kind') or '').strip() or 'VI'
out.append((f'vi:{t}', f'거래소 VI 발동 ({kind})'))
return out
if not codes:
return {}
today = datetime.now(KST).strftime('%Y-%m-%d')
cache = load_json(LIMITS_CACHE, {})
by_code = cache.get('by_code') if cache.get('date') == today else {}
if not isinstance(by_code, dict):
by_code = {}
missing = [c for c in codes if c not in by_code]
if missing:
try:
rows = kc.get_watchlist_quotes(missing) or {}
except Exception as e:
print(f'[warn] 상하한가 조회 실패 (다음 사이클 재시도): {e}', file=sys.stderr)
return {c: v for c, v in by_code.items() if v.get('upl')}
for c in missing:
r = rows.get(c) or {}
upl, lst = r.get('upper_limit') or 0, r.get('lower_limit') or 0
by_code[c] = {'upl': upl, 'lst': lst} if (upl and lst) else {}
save_json(LIMITS_CACHE, {'date': today, 'by_code': by_code})
return {c: v for c, v in by_code.items() if v.get('upl')}
# ---------------- 판정 ----------------
def _limit_hit(price: int, limits: dict | None) -> str | None:
"""'up'(상한가) | 'down'(하한가) | None. 거래소 제한가와 현재가 비교."""
if not limits or not price:
return None
upl, lst = limits.get('upl') or 0, limits.get('lst') or 0
if upl and price >= upl:
return 'up'
if lst and price <= lst:
return 'down'
return None
def _quantile_triggers(pct: float, thr: dict | None) -> list[dict]:
"""분위수 트리거만 — [{key, reason, word}]."""
if not thr or not thr.get('thr'):
return out
return []
direction = 'up' if pct > 0 else 'down'
word = '급등' if pct > 0 else '급락'
t1, t2 = thr['thr'], thr.get('thr_big') or 0
top1 = round((1 - SURGE_PCTL) * 100)
top2 = round((1 - SURGE_PCTL_BIG) * 100)
out: list[dict] = []
if t2 and abs(pct) >= t2:
out.append((f'{direction}2', f'{thr["n"]}일 중 상위 {top2}% 움직임 (문턱 {t2:.1f}%)'))
out.append({'key': f'{direction}2', 'word': word,
'reason': f'{thr["n"]}일 중 상위 {top2}% 움직임 (문턱 {t2:.1f}%)'})
if abs(pct) >= t1:
out.append((f'{direction}', f'{thr["n"]}일 중 상위 {top1}% 움직임 (문턱 {t1:.1f}%)'))
out.append({'key': direction, 'word': word,
'reason': f'{thr["n"]}일 중 상위 {top1}% 움직임 (문턱 {t1:.1f}%)'})
return out
def evaluate(pct: float, price: int, thr: dict | None, vi: dict | None,
limits: dict | None) -> tuple[list[dict], list[dict], str]:
"""(즉시 알릴 트리거, 보류할 트리거, 가림사유).
거래소 판정(·하한가·VI) 걸려 있는 동안 분위수 트리거는 **보류**한다. 같은 사건을
알리지 않으면서도, 사이 문턱을 넘은 사실은 버리지 않고 풀린 뒤에 내보내기 위해서다.
가림사유가 문자열이면 가린 것이 없다는 호출측은 이때 보류분을 방출한다.
가림 여부는 `defer` 비었는지로 판단하면 된다. VI 중에 주가가 문턱 아래로
되밀리면 defer 비는데, 그걸 '가림 해제' 읽으면 VI 도중에 보류분이 새어나간다.
"""
now: list[dict] = []
hit = _limit_hit(price, limits)
if hit == 'up':
now.append({'key': 'limit_up', 'word': '상한가',
'reason': f'거래소 상한가 도달 ({(limits or {}).get("upl", 0):,}원)'})
elif hit == 'down':
now.append({'key': 'limit_down', 'word': '하한가',
'reason': f'거래소 하한가 도달 ({(limits or {}).get("lst", 0):,}원)'})
vi_on = bool(vi and vi.get('active'))
if vi_on:
t = (vi.get('trigger_time') or '').strip() or 'na'
kind = (vi.get('apply_kind') or '').strip() or 'VI'
now.append({'key': f'vi:{t}', 'word': None, 'reason': f'거래소 VI 발동 ({kind})'})
quantile = _quantile_triggers(pct, thr)
if hit or vi_on:
return now, quantile, ({'up': '상한가', 'down': '하한가'}.get(hit) or 'VI')
return now + quantile, [], ''
def build_message(records: list[dict]) -> str:
ts = datetime.now(KST).strftime('%m/%d %H:%M')
lines = [f'[급등락] {ts}', f'{len(records)}건 감지']
for r in records:
pct = r['pct']
icon = '🚀' if pct > 0 else '🔻'
word = '급등' if pct > 0 else '급락'
# ⚠️ 아이콘은 pct 가 아니라 방향어에서 파생한다. 보류 방출 건은 돌파 시점 방향(급락)과
# 방출 시점 현재가 부호(+)가 어긋날 수 있어, pct 로 고르면 '🚀 급락'이 찍힌다.
word = r.get('word') or ('급등' if pct > 0 else '급락')
icon = {'상한가': '', '하한가': '', '급등': '🚀', '급락': '🔻'}.get(word, '🚀')
label = r['name'] or r['code']
lines.append('')
lines.append(f'{icon} #{label} {word} ({pct:+.2f}%)')
@@ -342,64 +422,114 @@ def run(dry: bool = False, force: bool = False) -> int:
for c, q in fetch_quotes_fallback(missing).items():
quotes[kc._clean_code(c)] = {'price': q.get('price'), 'pct': q.get('change_pct'), 'change': q.get('change')}
thr_map = get_threshold_map(codes)
limit_map = get_limit_map(codes)
today = datetime.now(KST).strftime('%Y-%m-%d')
day_state = load_json(ALERTS_STATE, {}).get(today) or {}
already = {f'{c}:{k}' for c, ks in day_state.items() if isinstance(ks, list) for k in ks}
# 보류분 — 감시에서 빠진 종목 것은 버린다(토글이 꺼졌으면 방출할 이유가 없다).
held = day_state.get(PENDING_KEY) or {}
pending = {c: v for c, v in held.items() if c in toggles and isinstance(v, dict)} if isinstance(held, dict) else {}
pending_sig = json.dumps(pending, sort_keys=True)
to_send: list[dict] = []
flushed: list[tuple[str, str]] = [] # (code, key) — 발송 성공 시에만 보류에서 지운다
def _rec(code: str, price: int, pct: float, trig: dict, vi: dict | None) -> dict:
r = {
'code': code,
'name': toggles.get(code) or '',
'price': price,
'pct': pct,
'key': trig['key'],
'word': trig.get('word'),
'reason': trig['reason'],
}
if vi and vi.get('active'):
r['vi_price'] = vi.get('trigger_price') or 0
return r
pending: list[dict] = []
for code in codes:
q = quotes.get(code) or {}
price = q.get('price')
pct = q.get('pct')
if not price or pct is None:
if not q.get('price') or q.get('pct') is None:
continue
price, pct = int(q['price']), float(q['pct'])
vi = vi_map.get(code)
for key, reason in evaluate(float(pct), thr_map.get(code), vi):
if f'{code}:{key}' in already:
now_trigs, defer_trigs, blocked_by = evaluate(
pct, price, thr_map.get(code), vi, limit_map.get(code))
for trig in now_trigs:
if f'{code}:{trig["key"]}' in already:
continue
rec = {
'code': code,
'name': toggles.get(code) or '',
'price': int(price),
'pct': float(pct),
'key': key,
'reason': reason,
to_send.append(_rec(code, price, pct, trig, vi))
already.add(f'{code}:{trig["key"]}')
if blocked_by:
# 가림 중 — 문턱 돌파 사실만 적어둔다. 이탈폭이 가장 컸던 시점을 남긴다.
for trig in defer_trigs:
if f'{code}:{trig["key"]}' in already:
continue
cur = (pending.get(code) or {}).get(trig['key']) or {}
if abs(pct) > abs(cur.get('pct') or 0):
pending.setdefault(code, {})[trig['key']] = {
'pct': pct, 'word': trig.get('word'),
'reason': trig['reason'], 'via': blocked_by,
}
continue
# 가림 해제 — 보류분 방출. 같은 키를 위에서 이미 즉시 알렸으면 already 가 걸러낸다.
for key, d in sorted((pending.get(code) or {}).items()):
if f'{code}:{key}' in already:
flushed.append((code, key))
continue
trig = {
'key': key, 'word': d.get('word'),
'reason': f'{d.get("reason") or "문턱 돌파"}{d.get("via") or "VI"} 중 최대 {d.get("pct", 0):+.2f}% 도달',
}
if vi and vi.get('active'):
rec['vi_price'] = vi.get('trigger_price') or 0
pending.append(rec)
to_send.append(_rec(code, price, pct, trig, vi))
already.add(f'{code}:{key}')
flushed.append((code, key))
if dry:
print(f'감시 {len(codes)}종목 / 허브연결 {connected} / 문턱 산출 {len(thr_map)}종목')
print(f'감시 {len(codes)}종목 / 허브연결 {connected} / 문턱 {len(thr_map)}종목 / 제한가 {len(limit_map)}종목')
for code in codes:
q = quotes.get(code) or {}
t = thr_map.get(code) or {}
thr = f'{t["thr"]:.1f}% / 확대 {t["thr_big"]:.1f}%' if t.get('thr') else '— (이력부족, VI만)'
print(f' {code} {toggles.get(code) or "":<12} 등락 {q.get("pct")}% / 문턱 {thr}'
f'{" / VI" if (vi_map.get(code) or {}).get("active") else ""}')
if pending:
lm = limit_map.get(code) or {}
thr = f'{t["thr"]:.1f}% / 확대 {t["thr_big"]:.1f}%' if t.get('thr') else '— (이력부족, VI·제한가만)'
lim = f'{lm["upl"]:,}/{lm["lst"]:,}' if lm.get('upl') else ''
print(f' {code} {toggles.get(code) or "":<12} 등락 {q.get("pct")}% / 문턱 {thr} / 상하한 {lim}'
f'{" / VI" if (vi_map.get(code) or {}).get("active") else ""}'
f'{" / 보류 " + ",".join(sorted(pending[code])) if pending.get(code) else ""}')
if to_send:
print('--- DRY ---')
print(build_message(pending))
print(f'done. watched={len(codes)} triggered={len(pending)}')
print(build_message(to_send))
print(f'done. watched={len(codes)} triggered={len(to_send)} pending={sum(len(v) for v in pending.values())}')
return 0
if not pending:
ok = send_telegram(build_message(to_send)) if to_send else True
if ok:
for code, key in flushed:
bucket = pending.get(code) or {}
bucket.pop(key, None)
if not bucket:
pending.pop(code, None)
if not to_send and json.dumps(pending, sort_keys=True) == pending_sig:
print(f'done. watched={len(codes)} triggered=0')
return 0
if send_telegram(build_message(pending)):
with alerts_lock():
latest = _prune(load_json(ALERTS_STATE, {}), today)
day = latest.setdefault(today, {})
for r in pending:
with alerts_lock():
latest = _prune(load_json(ALERTS_STATE, {}), today)
day = latest.setdefault(today, {})
if ok:
for r in to_send:
bucket = day.setdefault(r['code'], [])
if r['key'] not in bucket:
bucket.append(r['key'])
print(f'alerted {r["code"]} {r["name"]}:{r["key"]} {r["pct"]:+.2f}% @ {r["price"]:,}')
save_json(ALERTS_STATE, latest)
print(f'done. watched={len(codes)} triggered={len(pending)}')
if pending:
day[PENDING_KEY] = pending
else:
day.pop(PENDING_KEY, None)
save_json(ALERTS_STATE, latest)
print(f'done. watched={len(codes)} triggered={len(to_send)} pending={sum(len(v) for v in pending.values())}')
return 0
@@ -410,20 +540,26 @@ def cmd_list() -> int:
return 0
codes = sorted(toggles)
thr_map = get_threshold_map(codes)
limit_map = get_limit_map(codes)
top1 = round((1 - SURGE_PCTL) * 100)
top2 = round((1 - SURGE_PCTL_BIG) * 100)
print(f'감시 {len(codes)}종목 — 1차=자기이력 상위 {top1}% / 확대=상위 {top2}%')
print(f'{"종목":<16}{"전일종가":>10}{"1차":>7}{"급등가":>10}{"급락가":>10}{"확대":>7}')
print(f'{"종목":<16}{"전일종가":>10}{"1차":>7}{"급등가":>10}{"급락가":>10}{"확대":>7}'
f'{"상한가":>10}{"하한가":>10}')
for c in codes:
t = thr_map.get(c) or {}
lm = limit_map.get(c) or {}
name = (toggles[c] or c)[:15]
lim = f'{lm["upl"]:>10,}{lm["lst"]:>10,}' if lm.get('upl') else f'{"":>10}{"":>10}'
if not t.get('thr'):
print(f'{name:<16}{"이력 부족 — VI만 감시":>30}')
print(f'{name:<16}{"이력 부족 — VI·제한가":>30}{lim}')
continue
pv, t1, t2 = t['prev_close'], t['thr'], t['thr_big']
print(f'{name:<16}{pv:>10,}{t1:>6.1f}%{round(pv * (1 + t1 / 100)):>10,}'
f'{round(pv * (1 - t1 / 100)):>10,}{t2:>6.1f}%')
print(f'\nVI 발동은 이 문턱과 별개로 먼저 알림 (정적VI 전일종가 ±10% 부근)')
f'{round(pv * (1 - t1 / 100)):>10,}{t2:>6.1f}%{lim}')
print(f'\n상·하한가 도달은 문턱과 별개로 항상 알림 (거래소 계산값, 하루 1콜 캐시)')
print(f'※ VI 발동은 이 문턱과 별개로 먼저 알림 (정적VI 전일종가 ±10% 부근)')
print(f'※ 상·하한가·VI 중 문턱을 넘으면 보류했다가 풀린 뒤 알림 (사실을 버리지 않음)')
print(f'※ 문턱은 하루 가격제한폭 ±30% 안에 있음이 보장됨 (실제 관측된 이탈폭의 분위수)')
return 0