auto: 일일 백업 2026-08-05 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,7 @@ STOCK_NOTES = WORKSPACE / 'state' / 'behive_stock_notes.json'
|
||||
CHART_HLINES = WORKSPACE / 'state' / 'behive_chart_hlines.json'
|
||||
INTEREST_GROUPS = WORKSPACE / 'state' / 'behive_interest_groups.json'
|
||||
HOLDEVAL_SAVED = WORKSPACE / 'state' / 'behive_holdeval_saved.json'
|
||||
SURGE_TOGGLES = WORKSPACE / 'state' / 'behive_surge_toggles.json'
|
||||
ALERTS_STATE = WORKSPACE / 'state' / 'watchlist_alerts.json'
|
||||
HOLIDAYS_FILE = WORKSPACE / 'state' / 'market_holidays.json'
|
||||
SNAPSHOT_FILE = WORKSPACE / 'state' / 'portfolio_daily_snapshot.json'
|
||||
@@ -2209,6 +2210,93 @@ def _note_button_html(code: str, name: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _surge_toggles_lock():
|
||||
"""SURGE_TOGGLES 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
|
||||
import fcntl as _fcntl
|
||||
from contextlib import contextmanager as _cm
|
||||
|
||||
@_cm
|
||||
def _ctx():
|
||||
lock_path = SURGE_TOGGLES.with_suffix(SURGE_TOGGLES.suffix + '.lock')
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
f = open(lock_path, 'a')
|
||||
try:
|
||||
_fcntl.flock(f.fileno(), _fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
_fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
|
||||
finally:
|
||||
f.close()
|
||||
return _ctx()
|
||||
|
||||
|
||||
def _load_surge_toggles() -> dict:
|
||||
"""{'by_code': {code: {'name': str}}}. 급등락 알림 감시 대상 (surge_monitor.py 가 읽는다).
|
||||
|
||||
⚠️ 키는 종목코드 전용 — 메모(by_name fallback 있음)와 달리 코드 없는 종목은 감시할 수 없다
|
||||
(모니터가 시세 조회에 코드를 써야 하므로).
|
||||
"""
|
||||
if not SURGE_TOGGLES.exists():
|
||||
return {'by_code': {}}
|
||||
try:
|
||||
d = json.loads(SURGE_TOGGLES.read_text())
|
||||
if not isinstance(d.get('by_code'), dict):
|
||||
d['by_code'] = {}
|
||||
return d
|
||||
except Exception:
|
||||
return {'by_code': {}}
|
||||
|
||||
|
||||
def _is_surge_on(code: str | None) -> bool:
|
||||
code = (code or '').strip()
|
||||
if not code:
|
||||
return False
|
||||
return bool(_load_surge_toggles().get('by_code', {}).get(code))
|
||||
|
||||
|
||||
def _set_surge_toggle(code: str, name: str, on: bool) -> bool:
|
||||
"""급등락 알림 on/off. 반환값은 적용 후 상태."""
|
||||
code = ''.join(ch for ch in (code or '').strip() if ch.isalnum())
|
||||
name = (name or '').strip()[:40]
|
||||
if not code:
|
||||
raise ValueError('종목코드가 필요합니다 (급등락 감시는 코드 기준)')
|
||||
SURGE_TOGGLES.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _surge_toggles_lock():
|
||||
d = _load_surge_toggles()
|
||||
if on:
|
||||
d['by_code'][code] = {'name': name}
|
||||
else:
|
||||
d['by_code'].pop(code, None)
|
||||
tmp = SURGE_TOGGLES.with_suffix('.json.tmp')
|
||||
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
|
||||
tmp.replace(SURGE_TOGGLES)
|
||||
return on
|
||||
|
||||
|
||||
def _surge_toggle_button_html(code: str, name: str) -> str:
|
||||
"""detail-name 줄(종목명 옆, `+ 태그` 왼쪽)의 급등락 알림 토글. 켜짐이면 🔔 + has-surge 강조.
|
||||
|
||||
문턱은 종목별 ATR14 × K 로 surge_monitor 가 계산한다 (고정 % 아님) — 여기선 on/off만.
|
||||
⚠️ 아이콘 하나뿐이라(관리자님 요청, 2026-08-04) 무슨 버튼인지는 title 툴팁이 유일한 설명이다.
|
||||
"""
|
||||
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 '급등락 알림 꺼짐 — 누르면 켭니다'
|
||||
return (
|
||||
f'<button type="button" class="{cls}" data-surge-toggle="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>'
|
||||
)
|
||||
|
||||
|
||||
def _chart_hlines_lock():
|
||||
"""CHART_HLINES 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
|
||||
import fcntl as _fcntl
|
||||
@@ -2918,10 +3006,12 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
|
||||
tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '')
|
||||
# 관심종목은 그룹 버튼도 +태그 옆에 (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 '')
|
||||
detail_name_html = ''
|
||||
if not is_pending:
|
||||
code_span = f'<span class="detail-code">{code}</span>' if code else ''
|
||||
detail_name_html = f'<div class="detail-name">{stock}{code_span}{tag_add_btn}{group_add_btn}</div>'
|
||||
detail_name_html = f'<div class="detail-name">{stock}{code_span}{surge_btn}{tag_add_btn}{group_add_btn}</div>'
|
||||
# 현재가 아래 메타 라인 — 관심·감시는 비중·계좌 없어 시장(KOSPI/KOSDAQ)만.
|
||||
_mkt_label = _market_label_html(code)
|
||||
market_meta_html = f'<div class="detail-meta">{_mkt_label}</div>' if _mkt_label else ''
|
||||
@@ -4056,6 +4146,8 @@ 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 '')
|
||||
note_btn = _note_button_html(r.get('code') or '', r.get('stock') or '')
|
||||
trade_btn = (
|
||||
f'<div class="actions">'
|
||||
@@ -4081,7 +4173,7 @@ 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 ''}<span class="detail-owner owner-{owner_cls}">{owner_disp}</span>{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>{surge_btn}{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 ''}
|
||||
<div class="detail-meta"><span class="dm-item">비중 {weight:.2f}%</span><span class="dm-item">{acct_label}</span>{_market_label_html(code)}</div>
|
||||
@@ -5835,6 +5927,12 @@ 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); }
|
||||
.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; }
|
||||
@@ -10894,6 +10992,16 @@ def render_html() -> str:
|
||||
'if(curLeader)b.classList.add("active");else b.classList.remove("active");'
|
||||
'});'
|
||||
'}'
|
||||
# 급등락 토글 아이콘 즉시 반영. 같은 종목이 여러 패널에 중복 노출되므로 code로 전 인스턴스를 칠한다.
|
||||
'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";'
|
||||
'b.classList.toggle("has-surge",!!on);'
|
||||
'b.textContent=on?"\\uD83D\\uDD14":"\\uD83D\\uDD15";'
|
||||
'b.title=on?"급등락 알림 켜짐 — 누르면 끕니다":"급등락 알림 꺼짐 — 누르면 켭니다";'
|
||||
'});'
|
||||
'}'
|
||||
'function populateNoteForm(trigger){'
|
||||
'var nm=document.getElementById("note-modal");if(!nm)return;'
|
||||
'var name=trigger.getAttribute("data-note-stock")||"";'
|
||||
@@ -11100,6 +11208,38 @@ def render_html() -> str:
|
||||
'openModal("note-modal");'
|
||||
'return;'
|
||||
'}'
|
||||
# 급등락 알림 토글 — 낙관적 갱신(즉시 반영) 후 POST. 실패하면 되돌린다.
|
||||
# ⚠️ 패널 전체 새로고침(__behive_load)을 부르지 않는다 — 키움 조회까지 다시 돌아
|
||||
# 버튼이 몇 초 뒤에야 바뀐다. 토글 말고 달라지는 게 없어서 DOM만 고치면 충분하고,
|
||||
# 서버는 이미 _invalidate_panels_cache 했으니 다음 자동 새로고침이 알아서 일치시킨다.
|
||||
'var surgeBtn=t.closest&&t.closest("[data-surge-toggle]");'
|
||||
'if(surgeBtn){'
|
||||
'e.preventDefault();e.stopPropagation();'
|
||||
'if(surgeBtn.dataset.busy==="1")return;'
|
||||
'surgeBtn.dataset.busy="1";'
|
||||
'var sCode=surgeBtn.dataset.surgeCode||"";'
|
||||
'var sName=surgeBtn.dataset.surgeName||"";'
|
||||
'var wasOn=surgeBtn.dataset.surgeOn==="1";'
|
||||
'paintSurge(sCode,!wasOn);'
|
||||
'var body=new URLSearchParams();'
|
||||
'body.append("code",sCode);'
|
||||
'body.append("stock",sName);'
|
||||
'body.append("on",wasOn?"0":"1");'
|
||||
'fetch("/surge/toggle",{method:"POST",headers:{"Accept":"application/json","Content-Type":"application/x-www-form-urlencoded"},body:body.toString(),credentials:"same-origin"})'
|
||||
'.then(function(r){return r.json().catch(function(){return {ok:false,error:"HTTP "+r.status};});})'
|
||||
'.then(function(j){'
|
||||
'if(j&&j.ok){'
|
||||
'paintSurge(sCode,!!j.on);'
|
||||
'showToast((sName||"종목")+" 급등락 알림 "+(j.on?"켰습니다":"껐습니다"));'
|
||||
'}else{'
|
||||
'paintSurge(sCode,wasOn);'
|
||||
'showToast("급등락 알림 변경 실패: "+((j&&j.error)||"오류"));'
|
||||
'}'
|
||||
'})'
|
||||
'.catch(function(){paintSurge(sCode,wasOn);showToast("네트워크 오류");})'
|
||||
'.finally(function(){surgeBtn.dataset.busy="";});'
|
||||
'return;'
|
||||
'}'
|
||||
'var gAssign=t.closest&&t.closest("[data-group-assign]");'
|
||||
'if(gAssign){'
|
||||
'e.preventDefault();e.stopPropagation();'
|
||||
@@ -15090,6 +15230,44 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
if self.path == '/surge/toggle':
|
||||
# 급등락 알림 on/off. code 필수 (모니터가 코드로 시세를 조회하므로 name fallback 없음).
|
||||
# on 파라미터 없으면 현재 상태를 뒤집는다.
|
||||
code = (params.get('code') or [''])[0].strip()
|
||||
on_raw = (params.get('on') or [''])[0].strip()
|
||||
if not code:
|
||||
if wants_json:
|
||||
self._send_json(400, {'ok': False, 'error': '종목코드 없음'})
|
||||
else:
|
||||
self.send_error(400, 'Bad Request', explain='종목코드 없음')
|
||||
return
|
||||
try:
|
||||
on = (on_raw in ('1', 'true', 'on')) if on_raw else (not _is_surge_on(code))
|
||||
on = _set_surge_toggle(code, stock, on)
|
||||
_invalidate_panels_cache()
|
||||
except ValueError as e:
|
||||
if wants_json:
|
||||
self._send_json(400, {'ok': False, 'error': str(e)})
|
||||
else:
|
||||
self.send_error(400, 'Bad Request', explain=str(e))
|
||||
return
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
if wants_json:
|
||||
self._send_json(500, {'ok': False, 'error': str(e)})
|
||||
else:
|
||||
self.send_error(500, 'Internal Error', explain=str(e))
|
||||
return
|
||||
sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} code={code} stock={stock} on={on} → ok\n')
|
||||
sys.stdout.flush()
|
||||
if wants_json:
|
||||
self._send_json(200, {'ok': True, 'on': on})
|
||||
else:
|
||||
self.send_response(303)
|
||||
self.send_header('Location', '/')
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
# /stock/<code>/generate — 새 보고서 큐 등록
|
||||
if self.path.startswith('/stock/') and self.path.endswith('/generate'):
|
||||
tail = self.path[len('/stock/'):-len('/generate')]
|
||||
|
||||
Reference in New Issue
Block a user