auto: 일일 백업 2026-08-07 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)}&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
|
||||
|
||||
|
||||
@@ -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'\n※ VI 발동은 이 문턱과 별개로 먼저 알림 (정적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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user