auto: 일일 백업 2026-07-04 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -31,10 +31,18 @@ from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
WORKSPACE = Path(__file__).resolve().parent.parent
|
||||
# 빌드 식별자 — 파일 mtime 기반, import 시 1회 계산. 코드 수정 후 데몬 재시작하면 값이 바뀐다.
|
||||
# 클라이언트가 shell 로드 시 저장한 __build 와 /api/panels 응답의 build 가 다르면 1회 자동 리로드
|
||||
# → SPA식 fetch 갱신(location.reload 없음)이 옛 인라인 JS/CSS를 계속 쓰던 문제 해소.
|
||||
try:
|
||||
BUILD = str(int(Path(__file__).resolve().stat().st_mtime))
|
||||
except Exception:
|
||||
BUILD = '0'
|
||||
WATCHLIST = WORKSPACE / 'state' / 'behive_watchlist.json'
|
||||
INTERESTS = WORKSPACE / 'state' / 'behive_interests.json'
|
||||
STOCK_TAGS = WORKSPACE / 'state' / 'behive_stock_tags.json'
|
||||
STOCK_NOTES = WORKSPACE / 'state' / 'behive_stock_notes.json'
|
||||
INTEREST_GROUPS = WORKSPACE / 'state' / 'behive_interest_groups.json'
|
||||
ALERTS_STATE = WORKSPACE / 'state' / 'watchlist_alerts.json'
|
||||
HOLIDAYS_FILE = WORKSPACE / 'state' / 'market_holidays.json'
|
||||
SNAPSHOT_FILE = WORKSPACE / 'state' / 'portfolio_daily_snapshot.json'
|
||||
@@ -1875,11 +1883,181 @@ def _note_button_html(code: str, name: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _interest_groups_lock():
|
||||
"""INTEREST_GROUPS 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
|
||||
import fcntl as _fcntl
|
||||
from contextlib import contextmanager as _cm
|
||||
|
||||
@_cm
|
||||
def _ctx():
|
||||
lock_path = INTEREST_GROUPS.with_suffix(INTEREST_GROUPS.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_interest_groups() -> dict:
|
||||
"""관심종목 전용 그룹 레지스트리. {'groups': [순서있는 이름], 'members': {종목명: [그룹명]}}.
|
||||
stock_tags 와 독립. 멤버십 키는 종목명(interests 저장소가 종목명 키라 안정적)."""
|
||||
if not INTEREST_GROUPS.exists():
|
||||
return {'groups': [], 'members': {}}
|
||||
try:
|
||||
d = json.loads(INTEREST_GROUPS.read_text())
|
||||
if not isinstance(d.get('groups'), list):
|
||||
d['groups'] = []
|
||||
if not isinstance(d.get('members'), dict):
|
||||
d['members'] = {}
|
||||
return d
|
||||
except Exception:
|
||||
return {'groups': [], 'members': {}}
|
||||
|
||||
|
||||
def _save_interest_groups_locked(d: dict) -> None:
|
||||
"""atomic tmp write + replace. 반드시 _interest_groups_lock() 안에서 호출."""
|
||||
INTEREST_GROUPS.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = INTEREST_GROUPS.with_suffix('.json.tmp')
|
||||
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
|
||||
tmp.replace(INTEREST_GROUPS)
|
||||
|
||||
|
||||
def _get_interest_group_names(stock: str) -> list[str]:
|
||||
"""해당 종목이 속한 그룹명 리스트. 없으면 []."""
|
||||
stock = (stock or '').strip()
|
||||
if not stock:
|
||||
return []
|
||||
members = _load_interest_groups().get('members', {})
|
||||
v = members.get(stock)
|
||||
return list(v) if isinstance(v, list) else []
|
||||
|
||||
|
||||
def _create_interest_group(name: str) -> None:
|
||||
"""새 그룹 등록. trim·20자 cap. 빈/중복은 무시."""
|
||||
name = (name or '').strip().lstrip('#').strip()[:20]
|
||||
if not name:
|
||||
return
|
||||
with _interest_groups_lock():
|
||||
d = _load_interest_groups()
|
||||
if name not in d['groups']:
|
||||
d['groups'].append(name)
|
||||
_save_interest_groups_locked(d)
|
||||
|
||||
|
||||
def _rename_interest_group(old: str, new: str) -> None:
|
||||
"""그룹 이름 변경. groups 위치 보존 + members 전 리스트에서 old→new 치환."""
|
||||
old = (old or '').strip()
|
||||
new = (new or '').strip().lstrip('#').strip()[:20]
|
||||
if not old or not new or old == new:
|
||||
return
|
||||
with _interest_groups_lock():
|
||||
d = _load_interest_groups()
|
||||
if old not in d['groups']:
|
||||
return
|
||||
# groups: 위치 보존 교체 (new 이미 있으면 중복 제거)
|
||||
d['groups'] = [new if g == old else g for g in d['groups']]
|
||||
seen: list[str] = []
|
||||
for g in d['groups']:
|
||||
if g not in seen:
|
||||
seen.append(g)
|
||||
d['groups'] = seen
|
||||
# members: 각 리스트 old→new, 중복 제거
|
||||
for stock, names in list(d['members'].items()):
|
||||
if old in names:
|
||||
repl = [new if n == old else n for n in names]
|
||||
dedup: list[str] = []
|
||||
for n in repl:
|
||||
if n not in dedup:
|
||||
dedup.append(n)
|
||||
d['members'][stock] = dedup
|
||||
_save_interest_groups_locked(d)
|
||||
|
||||
|
||||
def _delete_interest_group(name: str) -> None:
|
||||
"""그룹 삭제. groups 에서 제거 + members 전 리스트에서 strip, 빈 리스트 키 삭제."""
|
||||
name = (name or '').strip()
|
||||
if not name:
|
||||
return
|
||||
with _interest_groups_lock():
|
||||
d = _load_interest_groups()
|
||||
d['groups'] = [g for g in d['groups'] if g != name]
|
||||
for stock, names in list(d['members'].items()):
|
||||
if name in names:
|
||||
remain = [n for n in names if n != name]
|
||||
if remain:
|
||||
d['members'][stock] = remain
|
||||
else:
|
||||
d['members'].pop(stock, None)
|
||||
_save_interest_groups_locked(d)
|
||||
|
||||
|
||||
def _reorder_interest_groups(ordered: list[str]) -> None:
|
||||
"""표시 순서 재배치. 기존과 동일한 이름집합만 허용(누락분은 기존 순서로 뒤에 append)."""
|
||||
with _interest_groups_lock():
|
||||
d = _load_interest_groups()
|
||||
existing = d['groups']
|
||||
new_order = [g for g in ordered if g in existing]
|
||||
for g in existing:
|
||||
if g not in new_order:
|
||||
new_order.append(g)
|
||||
d['groups'] = new_order
|
||||
_save_interest_groups_locked(d)
|
||||
|
||||
|
||||
def _set_interest_groups(stock: str, names: list[str]) -> None:
|
||||
"""종목의 그룹 소속 편집. groups 에 존재하는 이름만 반영. 빈이면 members 키 삭제."""
|
||||
stock = (stock or '').strip()
|
||||
if not stock:
|
||||
raise ValueError('종목명이 필요합니다')
|
||||
with _interest_groups_lock():
|
||||
d = _load_interest_groups()
|
||||
valid = [n for n in names if n in d['groups']]
|
||||
dedup: list[str] = []
|
||||
for n in valid:
|
||||
if n not in dedup:
|
||||
dedup.append(n)
|
||||
if dedup:
|
||||
d['members'][stock] = dedup
|
||||
else:
|
||||
d['members'].pop(stock, None)
|
||||
_save_interest_groups_locked(d)
|
||||
|
||||
|
||||
def _interest_group_button_html(stock: str) -> str:
|
||||
"""관심종목 행의 '그룹' 버튼. 클릭 시 group-assign-modal 열림.
|
||||
현재 소속 + 전체 그룹 목록을 data 속성으로 실어보냄. 디자인은 +태그 버튼(btn-add-tag)과 동일."""
|
||||
name_s = (stock or '').strip()
|
||||
if not name_s:
|
||||
return ''
|
||||
current = _get_interest_group_names(name_s)
|
||||
all_groups = _load_interest_groups().get('groups', [])
|
||||
name_attr = html.escape(name_s, quote=True)
|
||||
cur_attr = html.escape(','.join(current), quote=True)
|
||||
all_attr = html.escape(','.join(all_groups), quote=True)
|
||||
return (
|
||||
f'<button type="button" class="btn-add-tag" data-group-assign="1"'
|
||||
f' data-group-stock="{name_attr}" data-group-current="{cur_attr}"'
|
||||
f' data-group-all="{all_attr}" title="그룹 배정">+ 그룹</button>'
|
||||
)
|
||||
|
||||
|
||||
def _apply_interests_action(action: str, stock: str, payload: dict | None = None) -> None:
|
||||
"""관심종목 add/delete. 자동 적재 없는 수동 리스트라 pending_delete trash 단계 없이 즉시 삭제."""
|
||||
INTERESTS.parent.mkdir(parents=True, exist_ok=True)
|
||||
with _interests_lock():
|
||||
_apply_interests_action_locked(action, stock, payload)
|
||||
if action == 'delete':
|
||||
# 삭제 성공 후 그룹 멤버십 연쇄 정리. _interests_lock 밖에서 별도 파일 락 → 데드락 회피.
|
||||
try:
|
||||
_set_interest_groups(stock, [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _apply_interests_action_locked(action: str, stock: str, payload: dict | None = None) -> None:
|
||||
@@ -2244,10 +2422,12 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
|
||||
|
||||
# +태그 버튼 — held 행과 동일하게 펼침 상세 종목명 줄 오른쪽에 둔다 (actions 아님).
|
||||
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 ''
|
||||
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}</div>'
|
||||
detail_name_html = f'<div class="detail-name">{stock}{code_span}{tag_add_btn}{group_add_btn}</div>'
|
||||
|
||||
if source == 'interests':
|
||||
def _num_attr(field, sub):
|
||||
@@ -4609,7 +4789,7 @@ dl.pending-detail .pending-line-meta { margin-top: 2px; }
|
||||
.detail .block.chart-svg .chart-tabs button.active { background: rgba(251,191,36,0.12); color: #fbbf24; border-color: #fbbf24; }
|
||||
.detail .block.chart-svg .chart-panel { display: none; }
|
||||
.detail .block.chart-svg .chart-panel.active { display: block; }
|
||||
.detail .block.chart-svg .chart-cap { margin-top: 6px; padding: 6px 4px 0; font-size: 8px; line-height: 1.5; color: #7a8493; }
|
||||
.detail .block.chart-svg .chart-cap { margin-top: 6px; padding: 6px 4px 0; font-size: 11px; line-height: 1.5; color: #7a8493; }
|
||||
.detail .block.chart-svg .chart-cap b { color: #9aa3b0; font-weight: 600; }
|
||||
.detail .block.chart-svg .chart-subtabs { display: flex; gap: 4px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||
.detail .block.chart-svg .chart-subtabs button { padding: 3px 9px; background: transparent; color: #7a8493; border: 1px solid #2a2f3a; border-radius: 5px; cursor: pointer; font-size: 11px; font-weight: 600; font-family: inherit; }
|
||||
@@ -4659,10 +4839,41 @@ dl.pending-detail .pending-line-meta { margin-top: 2px; }
|
||||
|
||||
.empty { padding: 60px 18px; text-align: center; color: #6c7180; }
|
||||
|
||||
.add-trigger-wrap { padding: 20px 14px 28px; display: flex; justify-content: center; }
|
||||
.btn-add-trigger { background: #14171f; color: #e6e6e6; border: 1px dashed #2f3543; border-radius: 10px; padding: 12px 22px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; letter-spacing: 0.02em; }
|
||||
.btn-add-trigger:hover { border-color: #ff4d5e; color: #ff4d5e; }
|
||||
.btn-add-trigger:active { transform: translateY(1px); }
|
||||
/* 관심종목 그룹 선택 — 풀다운 + 오른쪽 '+' 메뉴. .sub-tab 과 별개 클래스. */
|
||||
.igrp-select-wrap { display: flex; align-items: center; gap: 8px; margin: 8px 14px 6px; }
|
||||
.igrp-select { flex: 1; min-width: 0; appearance: none; -webkit-appearance: none; background: #14171f url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path fill='%238b8f9a' d='M1 1l5 5 5-5'/></svg>") no-repeat right 12px center; color: #e6e6e6; border: 1px solid #1f2330; border-radius: 8px; padding: 9px 34px 9px 12px; font-size: 14px; font-weight: 500; font-family: inherit; cursor: pointer; }
|
||||
.igrp-select:focus { outline: none; border-color: #ff4d5e; }
|
||||
.igrp-flat-label { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; color: #d6d8dd; font-size: 14px; font-weight: 600; padding: 9px 2px; }
|
||||
.igrp-menu-wrap { position: relative; flex: 0 0 auto; }
|
||||
.igrp-add-btn { width: 38px; height: 38px; display: inline-flex; align-items: center; justify-content: center; background: #14171f; color: #e6e6e6; border: 1px solid #2f3543; border-radius: 8px; font-size: 22px; line-height: 1; cursor: pointer; font-family: inherit; }
|
||||
.igrp-add-btn:hover { border-color: #ff4d5e; color: #ff4d5e; }
|
||||
.igrp-add-btn:active { transform: translateY(1px); }
|
||||
.igrp-menu { position: absolute; right: 0; top: calc(100% + 4px); z-index: 60; min-width: 132px; background: #1a1d26; border: 1px solid #2f3543; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); padding: 4px; }
|
||||
.igrp-menu[hidden] { display: none; }
|
||||
.igrp-menu-item { display: block; width: 100%; text-align: left; background: transparent; border: 0; color: #e6e6e6; padding: 10px 12px; font-size: 14px; font-family: inherit; cursor: pointer; border-radius: 6px; white-space: nowrap; }
|
||||
.igrp-menu-item:hover { background: #232733; color: #ff6b78; }
|
||||
.igrp-panel[hidden] { display: none; }
|
||||
/* 그룹 배정 모달 체크박스 */
|
||||
.group-checks { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 10px; }
|
||||
.group-check { display: inline-flex; align-items: center; gap: 6px; background: #14171f; border: 1px solid #1f2330; border-radius: 8px; padding: 8px 12px; font-size: 14px; color: #e6e6e6; cursor: pointer; }
|
||||
.group-check input { accent-color: #ff4d5e; width: 16px; height: 16px; }
|
||||
/* 그룹 관리 모달 — 그룹당 2줄 카드(이름·수 / 액션) */
|
||||
.group-manage-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; max-height: 46vh; overflow-y: auto; }
|
||||
.group-manage-row { display: flex; flex-direction: column; gap: 8px; background: #14171f; border: 1px solid #1f2330; border-radius: 8px; padding: 10px; }
|
||||
.gmr-top { display: flex; align-items: center; gap: 10px; }
|
||||
.gmr-actions { display: flex; align-items: center; gap: 6px; }
|
||||
.gmr-spacer { flex: 1; }
|
||||
.group-name-input { flex: 1; min-width: 0; background: #0e1017; border: 1px solid #1f2330; border-radius: 6px; padding: 8px 10px; color: #e6e6e6; font-size: 15px; font-family: inherit; }
|
||||
.group-name-input:focus { outline: none; border-color: #ff4d5e; }
|
||||
.group-count { flex: 0 0 auto; white-space: nowrap; }
|
||||
.btn-mini { background: #0e1017; color: #cfd3dc; border: 1px solid #2f3543; border-radius: 6px; padding: 6px 10px; font-size: 12px; cursor: pointer; font-family: inherit; }
|
||||
.btn-mini:hover { border-color: #ff4d5e; color: #ff4d5e; }
|
||||
.btn-mini:disabled { opacity: 0.35; cursor: default; }
|
||||
.btn-mini.btn-delete { color: #ff8a95; }
|
||||
.group-new-row { display: flex; align-items: center; gap: 8px; }
|
||||
.group-new-row input { flex: 1; min-width: 0; background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 9px 10px; color: #e6e6e6; font-size: 16px; font-family: inherit; }
|
||||
.group-new-row input:focus { outline: none; border-color: #ff4d5e; }
|
||||
.group-new-row .btn-add { flex: 0 0 auto; }
|
||||
|
||||
.modal { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: max(60px, calc(env(safe-area-inset-top) + 68px)) 20px 20px; }
|
||||
.modal.modal-top { z-index: 1010; }
|
||||
@@ -6293,11 +6504,23 @@ def _render_watchlist_panel(cards: list[dict]) -> tuple[str, int]:
|
||||
return '\n'.join(sections), len(cards)
|
||||
|
||||
|
||||
def _render_interests_add_trigger() -> str:
|
||||
"""패널 본문 하단에 표시되는 모달 오픈 트리거. 폼 자체는 shell HTML의 모달에 고정."""
|
||||
def _interest_menu_html(reg: dict) -> str:
|
||||
"""풀다운 오른쪽 '+' 버튼 + 드롭다운 메뉴(종목 추가 / 그룹 관리). 관심종목 패널 상단 컨트롤 행에 둔다.
|
||||
그룹 관리 항목에 현재 그룹명·종목수를 실어보내 모달을 서버 왕복 없이 즉시 채운다."""
|
||||
all_groups = reg.get('groups', [])
|
||||
counts: dict = {}
|
||||
for names in reg.get('members', {}).values():
|
||||
for n in names:
|
||||
counts[n] = counts.get(n, 0) + 1
|
||||
all_attr = html.escape(','.join(all_groups), quote=True)
|
||||
counts_attr = html.escape(json.dumps(counts, ensure_ascii=True), quote=True)
|
||||
return (
|
||||
'<div class="add-trigger-wrap">'
|
||||
'<button type="button" class="btn-add-trigger" data-modal-open="interest-modal">+ 관심종목 추가</button>'
|
||||
'<div class="igrp-menu-wrap">'
|
||||
'<button type="button" class="igrp-add-btn" data-igrp-menu-toggle aria-label="추가" aria-expanded="false">+</button>'
|
||||
'<div class="igrp-menu" data-igrp-menu hidden>'
|
||||
'<button type="button" class="igrp-menu-item" data-modal-open="interest-modal">종목 추가</button>'
|
||||
f'<button type="button" class="igrp-menu-item" data-group-manage="1" data-group-all="{all_attr}" data-group-counts="{counts_attr}">그룹 관리</button>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
@@ -6409,6 +6632,59 @@ def _render_note_modal() -> str:
|
||||
)
|
||||
|
||||
|
||||
def _render_group_assign_modal() -> str:
|
||||
"""관심종목의 그룹 소속 편집 모달. 어느 관심종목 행이든 '그룹' 버튼이 동일하게 연다.
|
||||
체크박스는 JS가 data-group-all 로 렌더·data-group-current 로 체크. 새 그룹 인라인 추가 가능."""
|
||||
return (
|
||||
'<div id="group-assign-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="group-assign-modal-title">'
|
||||
'<div class="modal-overlay" data-modal-close="1"></div>'
|
||||
'<div class="modal-box" role="document">'
|
||||
'<div class="modal-head">'
|
||||
'<div class="modal-title" id="group-assign-modal-title">그룹 배정 — <span data-group-title></span></div>'
|
||||
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
|
||||
'</div>'
|
||||
'<form class="add-form" id="group-assign-form" method="post" action="/interests/groups/set">'
|
||||
'<input type="hidden" name="stock">'
|
||||
'<div class="group-checks" data-group-checks><div class="muted small">그룹이 없습니다. 아래에서 새로 만들어보세요.</div></div>'
|
||||
'<div class="add-form-grid">'
|
||||
'<label>새 그룹 <input name="new_group" maxlength="20" placeholder="예: 반도체 (선택)" autocomplete="off"></label>'
|
||||
'</div>'
|
||||
'<div class="form-feedback" data-feedback hidden></div>'
|
||||
'</form>'
|
||||
'<div class="modal-actions">'
|
||||
'<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
|
||||
'<button type="submit" form="group-assign-form" class="btn-add">저장</button>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
def _render_group_manage_modal() -> str:
|
||||
"""관심종목 그룹 레지스트리 관리 모달. 생성/이름변경/삭제/순서변경.
|
||||
각 op는 keepOpen fetch로 /interests/groups 호출 후 응답 groups 로 리스트 재렌더."""
|
||||
return (
|
||||
'<div id="group-manage-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="group-manage-modal-title">'
|
||||
'<div class="modal-overlay" data-modal-close="1"></div>'
|
||||
'<div class="modal-box" role="document">'
|
||||
'<div class="modal-head">'
|
||||
'<div class="modal-title" id="group-manage-modal-title">그룹 관리</div>'
|
||||
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
|
||||
'</div>'
|
||||
'<div class="group-manage-list" data-group-list></div>'
|
||||
'<div class="group-new-row">'
|
||||
'<input data-group-new-input maxlength="20" placeholder="새 그룹 이름" autocomplete="off">'
|
||||
'<button type="button" class="btn-add" data-group-new-add>추가</button>'
|
||||
'</div>'
|
||||
'<div class="form-feedback" data-feedback hidden></div>'
|
||||
'<div class="modal-actions">'
|
||||
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
def _render_candidate_modal() -> str:
|
||||
"""다중 매칭 시 사용자가 종목을 선택하는 별도 팝업. 추가 모달과 분리된 z-index 위층."""
|
||||
return (
|
||||
@@ -6691,23 +6967,67 @@ def _render_interests_modal() -> str:
|
||||
|
||||
def _render_interests_panel(cards: list[dict]) -> tuple[str, int]:
|
||||
sections: list[str] = []
|
||||
reg = _load_interest_groups()
|
||||
menu_html = _interest_menu_html(reg) # 풀다운 오른쪽 '+' 메뉴 (종목 추가 / 그룹 관리)
|
||||
if not cards:
|
||||
sections.append(
|
||||
f'<div class="igrp-select-wrap"><span class="igrp-flat-label">관심<span class="count">0</span></span>{menu_html}</div>'
|
||||
)
|
||||
sections.append('<div class="empty">관심종목이 비어있습니다.</div>')
|
||||
return '\n'.join(sections), 0
|
||||
|
||||
show_day_change = _show_day_change_now()
|
||||
for c in cards:
|
||||
c['_show_day_change'] = show_day_change
|
||||
held = sorted([c for c in cards if c.get('mode') == 'held'],
|
||||
key=lambda c: c.get('held_qty', 0) * (c.get('held_avg_price') or 0), reverse=True)
|
||||
watching = sorted([c for c in cards if c.get('mode') != 'held'],
|
||||
key=lambda c: c.get('saved_at', ''), reverse=True)
|
||||
ordered = held + watching # 전체·그룹 공용 정렬 (보유중 먼저, 관심은 저장순)
|
||||
|
||||
# 그룹 풀다운 — 레지스트리 순서대로 멤버 있는 그룹만 옵션으로. 다중 소속은 각 패널에 중복 노출.
|
||||
# 보유중은 mode=='held' 로 자동 채워지는 옵션(레지스트리 미등록). 선택 상태는 .sub-tab(자산탭)과
|
||||
# 충돌 없게 전용 클래스 .igrp-select / .igrp-panel 사용.
|
||||
members = reg.get('members', {})
|
||||
group_order = reg.get('groups', [])
|
||||
|
||||
def _card_groups(c):
|
||||
return [g for g in (members.get(c.get('stock') or '') or []) if g in group_order]
|
||||
|
||||
active_groups = [g for g in group_order if any(g in _card_groups(c) for c in ordered)]
|
||||
ungrouped = [c for c in watching if not _card_groups(c)] # 미분류 = 미보유 & 그룹 미배정
|
||||
|
||||
if not held and not active_groups:
|
||||
# 보유·그룹 없음 — 단일 목록 (풀다운 없이 라벨 + '+' 메뉴만).
|
||||
sections.append(
|
||||
f'<div class="igrp-select-wrap"><span class="igrp-flat-label">관심<span class="count">{len(watching)}</span></span>{menu_html}</div>'
|
||||
)
|
||||
sections.append('\n'.join(_render_row(c, source='interests') for c in watching))
|
||||
else:
|
||||
show_day_change = _show_day_change_now()
|
||||
for c in cards:
|
||||
c['_show_day_change'] = show_day_change
|
||||
held = sorted([c for c in cards if c.get('mode') == 'held'],
|
||||
key=lambda c: c.get('held_qty', 0) * (c.get('held_avg_price') or 0), reverse=True)
|
||||
watching = sorted([c for c in cards if c.get('mode') != 'held'],
|
||||
key=lambda c: c.get('saved_at', ''), reverse=True)
|
||||
tabs = [('__all__', '전체', ordered)]
|
||||
if held:
|
||||
sections.append(f'<div class="section-label">보유중<span class="count">{len(held)}</span></div>')
|
||||
sections.append('\n'.join(_render_row(c, source='interests') for c in held))
|
||||
if watching:
|
||||
sections.append(f'<div class="section-label">관심<span class="count">{len(watching)}</span></div>')
|
||||
sections.append('\n'.join(_render_row(c, source='interests') for c in watching))
|
||||
sections.append(_render_interests_add_trigger())
|
||||
tabs.append(('__held__', '보유중', held))
|
||||
for g in active_groups:
|
||||
tabs.append((g, g, [c for c in ordered if g in _card_groups(c)]))
|
||||
if ungrouped:
|
||||
tabs.append(('__ungrouped__', '미분류', ungrouped))
|
||||
opts = ''.join(
|
||||
f'<option value="{html.escape(key, quote=True)}"{" selected" if i == 0 else ""}>'
|
||||
f'{html.escape(label)} ({len(bucket)})</option>'
|
||||
for i, (key, label, bucket) in enumerate(tabs)
|
||||
)
|
||||
sections.append(
|
||||
f'<div class="igrp-select-wrap"><select class="igrp-select" data-igrp-select'
|
||||
f' aria-label="그룹 선택">{opts}</select>{menu_html}</div>'
|
||||
)
|
||||
panels = ''.join(
|
||||
f'<div class="igrp-panel" data-igrp-panel="{html.escape(key, quote=True)}" role="tabpanel"'
|
||||
f'{"" if i == 0 else " hidden"}>'
|
||||
+ '\n'.join(_render_row(c, source='interests') for c in bucket)
|
||||
+ '</div>'
|
||||
for i, (key, label, bucket) in enumerate(tabs)
|
||||
)
|
||||
sections.append(panels)
|
||||
return '\n'.join(sections), len(cards)
|
||||
|
||||
|
||||
@@ -6772,6 +7092,7 @@ def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_
|
||||
indices = _fetch_indices()
|
||||
return {
|
||||
'now': now,
|
||||
'build': BUILD,
|
||||
'market_active': market_active,
|
||||
'first_tab_id': 'tab-summary',
|
||||
'owner': owner,
|
||||
@@ -6942,6 +7263,12 @@ def render_html() -> str:
|
||||
'renderTicker();'
|
||||
'}'
|
||||
'function apply(p){'
|
||||
# 서버 코드가 갱신됐는데(=build 불일치, __build 없는 옛 페이지 포함) 옛 인라인 JS/CSS로 도는 경우
|
||||
# build 당 1회만 강제 리로드(sessionStorage 가드 — 리로드로도 안 맞으면 루프 없이 포기).
|
||||
'if(p.build&&window.__build!==p.build){'
|
||||
'var __rk="__bld_"+p.build;'
|
||||
'try{if(!sessionStorage.getItem(__rk)){sessionStorage.setItem(__rk,"1");location.reload();return;}}catch(_){location.reload();return;}'
|
||||
'}'
|
||||
'setMarket(!!p.market_active);'
|
||||
'setTicker(p.ticker_items||p.indices||[]);'
|
||||
'var byId={};(p.tabs||[]).forEach(function(t){'
|
||||
@@ -6963,8 +7290,8 @@ def render_html() -> str:
|
||||
'});'
|
||||
'sec.innerHTML=t.html;'
|
||||
'Object.keys(openKeys).forEach(function(k){'
|
||||
'var d=sec.querySelector(\'details.row[data-row-key="\'+k.replace(/"/g,\'\\\\"\')+\'"]\');'
|
||||
'if(d)d.setAttribute("open","");'
|
||||
# 그룹 탭에서 같은 종목이 전체·그룹 패널에 중복 존재 → 매칭되는 모든 행 복원.
|
||||
'sec.querySelectorAll(\'details.row[data-row-key="\'+k.replace(/"/g,\'\\\\"\')+\'"]\').forEach(function(d){d.setAttribute("open","");});'
|
||||
'});'
|
||||
'});'
|
||||
'document.dispatchEvent(new CustomEvent("behive:panels-loaded",{detail:p}));'
|
||||
@@ -7334,6 +7661,57 @@ def render_html() -> str:
|
||||
'applySub(root,key);'
|
||||
'subState=key;'
|
||||
'});'
|
||||
# 관심종목 그룹 선택 (.igrp-select 풀다운 / .igrp-panel) — .sub-tab 과 상태 분리. 3s swap 후 restoreIgrp 복원.
|
||||
'function applyIgrp(root,key){'
|
||||
'if(!root)return false;'
|
||||
'var panels=root.querySelectorAll(".igrp-panel");'
|
||||
'if(!panels.length)return false;'
|
||||
'var has=false;'
|
||||
'panels.forEach(function(p){if(p.getAttribute("data-igrp-panel")===key)has=true;});'
|
||||
'if(!has)return false;'
|
||||
'var sel=root.querySelector(".igrp-select");'
|
||||
'if(sel&&sel.value!==key)sel.value=key;'
|
||||
'panels.forEach(function(p){'
|
||||
'if(p.getAttribute("data-igrp-panel")===key)p.removeAttribute("hidden");'
|
||||
'else p.setAttribute("hidden","");'
|
||||
'});'
|
||||
'return true;'
|
||||
'}'
|
||||
'var igrpState="__all__";'
|
||||
'function restoreIgrp(){'
|
||||
'document.querySelectorAll(".igrp-select").forEach(function(s){'
|
||||
'var root=(s.closest&&s.closest("section"))||s.parentNode.parentNode;'
|
||||
'if(!applyIgrp(root,igrpState))applyIgrp(root,"__all__");'
|
||||
'});'
|
||||
'}'
|
||||
'document.addEventListener("change",function(e){'
|
||||
'var s=e.target&&e.target.closest&&e.target.closest(".igrp-select");'
|
||||
'if(!s)return;'
|
||||
'var key=s.value;if(key===null)return;'
|
||||
'var root=(s.closest&&s.closest("section"))||s.parentNode.parentNode;'
|
||||
'applyIgrp(root,key);'
|
||||
'igrpState=key;'
|
||||
'});'
|
||||
# 풀다운 오른쪽 '+' 버튼 → 작은 메뉴 토글. 항목 클릭·바깥 클릭은 닫음(항목은 기존 openBtn/gManage 핸들러로 전파).
|
||||
'function closeIgrpMenus(){'
|
||||
'document.querySelectorAll("[data-igrp-menu]:not([hidden])").forEach(function(m){m.setAttribute("hidden","");});'
|
||||
'document.querySelectorAll("[data-igrp-menu-toggle][aria-expanded=\\"true\\"]").forEach(function(b){b.setAttribute("aria-expanded","false");});'
|
||||
'}'
|
||||
'document.addEventListener("click",function(e){'
|
||||
'var tg=e.target.closest&&e.target.closest("[data-igrp-menu-toggle]");'
|
||||
'if(tg){'
|
||||
'e.preventDefault();'
|
||||
'var wrap=tg.closest(".igrp-menu-wrap");'
|
||||
'var menu=wrap&&wrap.querySelector("[data-igrp-menu]");'
|
||||
'if(!menu)return;'
|
||||
'var willOpen=menu.hasAttribute("hidden");'
|
||||
'closeIgrpMenus();'
|
||||
'if(willOpen){menu.removeAttribute("hidden");tg.setAttribute("aria-expanded","true");}'
|
||||
'return;'
|
||||
'}'
|
||||
# 그 외 클릭(메뉴 항목 포함) → 메뉴 닫기. 항목은 preventDefault 안 해 기존 핸들러로 계속 전파.
|
||||
'closeIgrpMenus();'
|
||||
'});'
|
||||
# 합산/각계좌별도 단일 토글 — 클릭마다 두 상태 swap. owner KPI 카드·자산정보 sub-panel 어디서나
|
||||
# 동일 in-memory state 공유. 패널 swap 후 restoreAccountViews가 모든 토글·pane을 다시 동기화.
|
||||
'function applyAccountView(key){'
|
||||
@@ -7434,7 +7812,7 @@ def render_html() -> str:
|
||||
'var ow=window.__behive_active_owner?window.__behive_active_owner():null;'
|
||||
'if(window.__behive_load)window.__behive_load(ow,true);'
|
||||
'});'
|
||||
'function scan(){document.querySelectorAll(".net-chart[data-pts]").forEach(init);restoreSubs();restoreAccountViews();}'
|
||||
'function scan(){document.querySelectorAll(".net-chart[data-pts]").forEach(init);restoreSubs();restoreAccountViews();restoreIgrp();}'
|
||||
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",scan);'
|
||||
'else scan();'
|
||||
# swap 후 새 차트가 들어오면 재바인딩 + sub-tab 복원
|
||||
@@ -7987,6 +8365,8 @@ def render_html() -> str:
|
||||
# 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();'
|
||||
@@ -8047,6 +8427,8 @@ def render_html() -> str:
|
||||
interest_edit_modal_html = _render_interests_edit_modal()
|
||||
tag_modal_html = _render_tag_modal()
|
||||
note_modal_html = _render_note_modal()
|
||||
group_assign_modal_html = _render_group_assign_modal()
|
||||
group_manage_modal_html = _render_group_manage_modal()
|
||||
trade_modal_html = _render_trade_modal()
|
||||
info_modal_html = _render_info_modal()
|
||||
info_desc_modal_html = _render_info_desc_modal()
|
||||
@@ -8660,6 +9042,68 @@ def render_html() -> str:
|
||||
'var pop=nm.querySelector("[data-note-confirm]");'
|
||||
'if(pop)pop.classList.add("hidden");'
|
||||
'}'
|
||||
'function populateGroupAssignForm(trigger){'
|
||||
'var gm=document.getElementById("group-assign-modal");if(!gm)return;'
|
||||
'var name=trigger.getAttribute("data-group-stock")||"";'
|
||||
'var curStr=trigger.getAttribute("data-group-current")||"";'
|
||||
'var allStr=trigger.getAttribute("data-group-all")||"";'
|
||||
'var cur=curStr?curStr.split(","):[];'
|
||||
'var all=allStr?allStr.split(","):[];'
|
||||
'var title=gm.querySelector("[data-group-title]");'
|
||||
'if(title)title.textContent=name;'
|
||||
'var f=gm.querySelector("#group-assign-form");if(!f)return;'
|
||||
'f.querySelector("input[name=stock]").value=name;'
|
||||
'var ni=f.querySelector("input[name=new_group]");if(ni)ni.value="";'
|
||||
'var box=gm.querySelector("[data-group-checks]");if(!box)return;'
|
||||
'if(!all.length){box.innerHTML=\'<div class="muted small">그룹이 없습니다. 아래에서 새로 만들어보세요.</div>\';return;}'
|
||||
'box.innerHTML="";'
|
||||
'all.forEach(function(g){'
|
||||
'var lab=document.createElement("label");lab.className="group-check";'
|
||||
'var cb=document.createElement("input");cb.type="checkbox";cb.name="groups";cb.value=g;'
|
||||
'if(cur.indexOf(g)>=0)cb.checked=true;'
|
||||
'var sp=document.createElement("span");sp.textContent=g;'
|
||||
'lab.appendChild(cb);lab.appendChild(sp);'
|
||||
'box.appendChild(lab);'
|
||||
'});'
|
||||
'}'
|
||||
'function renderGroupManageList(groups,counts){'
|
||||
'var gm=document.getElementById("group-manage-modal");if(!gm)return;'
|
||||
'var box=gm.querySelector("[data-group-list]");if(!box)return;'
|
||||
'window.__groupOrder=(groups||[]).slice();'
|
||||
'box.innerHTML="";'
|
||||
'if(!groups||!groups.length){box.innerHTML=\'<div class="muted small">아직 그룹이 없습니다. 아래에서 만들어보세요.</div>\';return;}'
|
||||
'groups.forEach(function(g,i){'
|
||||
'var row=document.createElement("div");row.className="group-manage-row";'
|
||||
'var top=document.createElement("div");top.className="gmr-top";'
|
||||
'var inp=document.createElement("input");inp.type="text";inp.className="group-name-input";inp.value=g;inp.setAttribute("data-group-orig",g);inp.maxLength=20;'
|
||||
'var cnt=document.createElement("span");cnt.className="muted small group-count";var n=(counts&&counts[g])||0;cnt.textContent=n+"종목";'
|
||||
'top.appendChild(inp);top.appendChild(cnt);'
|
||||
'var act=document.createElement("div");act.className="gmr-actions";'
|
||||
'var up=document.createElement("button");up.type="button";up.className="btn-mini";up.textContent="▲";up.title="위로";up.setAttribute("data-group-up",g);if(i===0)up.disabled=true;'
|
||||
'var dn=document.createElement("button");dn.type="button";dn.className="btn-mini";dn.textContent="▼";dn.title="아래로";dn.setAttribute("data-group-down",g);if(i===groups.length-1)dn.disabled=true;'
|
||||
'var spacer=document.createElement("span");spacer.className="gmr-spacer";'
|
||||
'var rn=document.createElement("button");rn.type="button";rn.className="btn-mini";rn.textContent="이름변경";rn.setAttribute("data-group-rename",g);'
|
||||
'var del=document.createElement("button");del.type="button";del.className="btn-mini btn-delete";del.textContent="삭제";del.setAttribute("data-group-delete",g);'
|
||||
'act.appendChild(up);act.appendChild(dn);act.appendChild(spacer);act.appendChild(rn);act.appendChild(del);'
|
||||
'row.appendChild(top);row.appendChild(act);'
|
||||
'box.appendChild(row);'
|
||||
'});'
|
||||
'}'
|
||||
'function doGroupOp(payload){'
|
||||
'var gm=document.getElementById("group-manage-modal");'
|
||||
'var data=new URLSearchParams();'
|
||||
'for(var k in payload){if(payload.hasOwnProperty(k))data.append(k,payload[k]);}'
|
||||
'return fetch("/interests/groups",{method:"POST",headers:{"Accept":"application/json","Content-Type":"application/x-www-form-urlencoded"},body:data.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){'
|
||||
'renderGroupManageList(j.groups||[],j.counts||{});'
|
||||
'if(window.__behive_load)window.__behive_load(null,true);'
|
||||
'}else if(gm){showError(gm,(j&&j.error)||"실패");}'
|
||||
'return j;'
|
||||
'})'
|
||||
'.catch(function(){if(gm)showError(gm,"네트워크 오류");});'
|
||||
'}'
|
||||
'document.addEventListener("click",function(e){'
|
||||
'var t=e.target;'
|
||||
'var closeAttr=t.closest&&t.closest("[data-modal-close]");'
|
||||
@@ -8783,6 +9227,65 @@ def render_html() -> str:
|
||||
'openModal("note-modal");'
|
||||
'return;'
|
||||
'}'
|
||||
'var gAssign=t.closest&&t.closest("[data-group-assign]");'
|
||||
'if(gAssign){'
|
||||
'e.preventDefault();e.stopPropagation();'
|
||||
'populateGroupAssignForm(gAssign);'
|
||||
'openModal("group-assign-modal");'
|
||||
'return;'
|
||||
'}'
|
||||
'var gManage=t.closest&&t.closest("[data-group-manage]");'
|
||||
'if(gManage){'
|
||||
'e.preventDefault();e.stopPropagation();'
|
||||
'var mAll=gManage.getAttribute("data-group-all")||"";'
|
||||
'var mNames=mAll?mAll.split(","):[];'
|
||||
'var mCounts={};try{mCounts=JSON.parse(gManage.getAttribute("data-group-counts")||"{}");}catch(_){}'
|
||||
'renderGroupManageList(mNames,mCounts);'
|
||||
'openModal("group-manage-modal");'
|
||||
'return;'
|
||||
'}'
|
||||
'var gNew=t.closest&&t.closest("[data-group-new-add]");'
|
||||
'if(gNew){'
|
||||
'e.preventDefault();'
|
||||
'var gmn=document.getElementById("group-manage-modal");'
|
||||
'var ip=gmn?gmn.querySelector("[data-group-new-input]"):null;'
|
||||
'var nvn=ip?ip.value.trim():"";if(!nvn)return;'
|
||||
'doGroupOp({op:"create",name:nvn}).then(function(){if(ip)ip.value="";});'
|
||||
'return;'
|
||||
'}'
|
||||
'var gRn=t.closest&&t.closest("[data-group-rename]");'
|
||||
'if(gRn){'
|
||||
'e.preventDefault();'
|
||||
'var orig=gRn.getAttribute("data-group-rename");'
|
||||
'var rrow=gRn.closest(".group-manage-row");'
|
||||
'var rinp=rrow?rrow.querySelector(".group-name-input"):null;'
|
||||
'var nvr=rinp?rinp.value.trim():"";'
|
||||
'if(!nvr||nvr===orig)return;'
|
||||
'doGroupOp({op:"rename",name:orig,new_name:nvr});'
|
||||
'return;'
|
||||
'}'
|
||||
'var gDel=t.closest&&t.closest("[data-group-delete]");'
|
||||
'if(gDel){'
|
||||
'e.preventDefault();'
|
||||
'var gd=gDel.getAttribute("data-group-delete");'
|
||||
'if(!confirm("그룹 \\""+gd+"\\" 을(를) 삭제할까요? (종목은 유지, 그룹 배정만 해제됩니다)"))return;'
|
||||
'doGroupOp({op:"delete",name:gd});'
|
||||
'return;'
|
||||
'}'
|
||||
'var gUp=t.closest&&t.closest("[data-group-up]");'
|
||||
'if(gUp){'
|
||||
'e.preventDefault();'
|
||||
'var gu=gUp.getAttribute("data-group-up");var ord=(window.__groupOrder||[]).slice();'
|
||||
'var iu=ord.indexOf(gu);if(iu>0){ord.splice(iu,1);ord.splice(iu-1,0,gu);doGroupOp({op:"reorder",order:ord.join(",")});}'
|
||||
'return;'
|
||||
'}'
|
||||
'var gDn=t.closest&&t.closest("[data-group-down]");'
|
||||
'if(gDn){'
|
||||
'e.preventDefault();'
|
||||
'var gdn=gDn.getAttribute("data-group-down");var ord2=(window.__groupOrder||[]).slice();'
|
||||
'var idn=ord2.indexOf(gdn);if(idn>=0&&idn<ord2.length-1){ord2.splice(idn,1);ord2.splice(idn+1,0,gdn);doGroupOp({op:"reorder",order:ord2.join(",")});}'
|
||||
'return;'
|
||||
'}'
|
||||
'var openBtn=t.closest&&t.closest("[data-modal-open]");'
|
||||
'if(openBtn){'
|
||||
'e.preventDefault();'
|
||||
@@ -8834,7 +9337,7 @@ def render_html() -> str:
|
||||
'document.addEventListener("submit",function(e){'
|
||||
'var form=e.target;'
|
||||
'if(!form)return;'
|
||||
'if(form.id==="interest-add-form"||form.id==="interest-edit-form"||form.id==="tag-form"||form.id==="note-form"){'
|
||||
'if(form.id==="interest-add-form"||form.id==="interest-edit-form"||form.id==="tag-form"||form.id==="note-form"||form.id==="group-assign-form"){'
|
||||
'e.preventDefault();'
|
||||
'submitForm(form);'
|
||||
'return;'
|
||||
@@ -8898,6 +9401,8 @@ def render_html() -> str:
|
||||
# details.row[data-row-key] 그룹 mutex — 새로 열리면 다른 열려있던 것 자동 닫음.
|
||||
# capture phase 사용: toggle 이벤트는 bubble 안 함. panels apply가 다시 그릴 때 setAttribute('open')
|
||||
# 호출이 다시 trigger되어도 한 개만 남는다.
|
||||
# ⚠️ 관심종목 그룹 탭은 같은 종목(=같은 data-row-key)이 전체·보유중·그룹 패널에 중복 존재한다.
|
||||
# 같은 키끼리는 닫지 않는다(탭 간 동기화, 새로고침 시 보이는 탭 행이 닫히던 버그). 다른 종목만 닫음.
|
||||
details_mutex_script = (
|
||||
'<script>(function(){'
|
||||
'document.addEventListener("toggle",function(e){'
|
||||
@@ -8905,10 +9410,20 @@ def render_html() -> str:
|
||||
'if(!d||d.nodeName!=="DETAILS")return;'
|
||||
'if(!d.classList.contains("row"))return;'
|
||||
'if(!d.hasAttribute("data-row-key"))return;'
|
||||
'if(!d.open)return;'
|
||||
'document.querySelectorAll("details.row[data-row-key][open]").forEach(function(o){'
|
||||
'if(o!==d)o.removeAttribute("open");'
|
||||
'});'
|
||||
'var k=d.getAttribute("data-row-key");'
|
||||
'if(d.open){'
|
||||
# 열림: 같은 키(그룹 탭 중복 행) 전부 열어 동기화 + 다른 키는 닫음(아코디언).
|
||||
'document.querySelectorAll("details.row[data-row-key]").forEach(function(o){'
|
||||
'if(o===d)return;'
|
||||
'if(o.getAttribute("data-row-key")===k){if(!o.open)o.setAttribute("open","");}'
|
||||
'else if(o.open)o.removeAttribute("open");'
|
||||
'});'
|
||||
'}else{'
|
||||
# 닫힘: 같은 키 중복 행 전부 닫아 동기화 — 숨은 탭 사본이 열린 채 남아 새로고침때 되살아나던 문제.
|
||||
'document.querySelectorAll("details.row[data-row-key][open]").forEach(function(o){'
|
||||
'if(o!==d&&o.getAttribute("data-row-key")===k)o.removeAttribute("open");'
|
||||
'});'
|
||||
'}'
|
||||
'},true);'
|
||||
'})();</script>'
|
||||
)
|
||||
@@ -10095,6 +10610,7 @@ window.openPinModal = openPinModal;
|
||||
<meta name="apple-mobile-web-app-title" content="자산현황">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>자산현황</title>
|
||||
<script>window.__build="{BUILD}";</script>
|
||||
<style>{_CSS}{dynamic_css}</style>
|
||||
{head_script}
|
||||
</head>
|
||||
@@ -10130,6 +10646,8 @@ window.openPinModal = openPinModal;
|
||||
{interest_edit_modal_html}
|
||||
{tag_modal_html}
|
||||
{note_modal_html}
|
||||
{group_assign_modal_html}
|
||||
{group_manage_modal_html}
|
||||
{candidate_modal_html}
|
||||
{trade_modal_html}
|
||||
{info_modal_html}
|
||||
@@ -10890,6 +11408,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
'sma5': sa.sma(closes, 5),
|
||||
'sma20': sa.sma(closes, 20),
|
||||
'sma60': sa.sma(closes, 60),
|
||||
'sma120': sa.sma(closes, 120),
|
||||
}
|
||||
# 마지막 봉이 오늘·정규장 진행형이면 세션경과 비율로 오늘 거래량을 하루치 환산(회전율 '예상')
|
||||
vfrac = None
|
||||
@@ -11366,6 +11885,97 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
if self.path == '/interests/groups/set':
|
||||
# 관심종목 그룹 멤버십 편집 — stock(name) 필수. groups 는 체크박스 다중값(반복 필드).
|
||||
# new_group 있으면 먼저 생성 후 포함.
|
||||
if not stock:
|
||||
if wants_json:
|
||||
self._send_json(400, {'ok': False, 'error': '종목명 누락'})
|
||||
else:
|
||||
self.send_error(400, 'Bad Request', explain='종목명 누락')
|
||||
return
|
||||
group_names = [g.strip() for g in (params.get('groups') or []) if g.strip()]
|
||||
new_group = (params.get('new_group') or [''])[0].strip()
|
||||
try:
|
||||
if new_group:
|
||||
_create_interest_group(new_group)
|
||||
group_names.append(new_group)
|
||||
_set_interest_groups(stock, group_names)
|
||||
_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} stock={stock} groups={group_names} → ok\n')
|
||||
sys.stdout.flush()
|
||||
if wants_json:
|
||||
self._send_json(200, {'ok': True})
|
||||
else:
|
||||
self.send_response(303)
|
||||
self.send_header('Location', '/#tab-interests')
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
if self.path == '/interests/groups':
|
||||
# 그룹 레지스트리 관리 — op ∈ {create, rename, delete, reorder}.
|
||||
# 관리 모달은 shell 직속이라 panels 리로드로 자동 갱신 안 됨 → 응답에 갱신 목록 동봉.
|
||||
op = (params.get('op') or [''])[0].strip()
|
||||
name = (params.get('name') or [''])[0].strip()
|
||||
new_name = (params.get('new_name') or [''])[0].strip()
|
||||
order_raw = (params.get('order') or [''])[0]
|
||||
try:
|
||||
if op == 'create':
|
||||
_create_interest_group(name)
|
||||
elif op == 'rename':
|
||||
_rename_interest_group(name, new_name)
|
||||
elif op == 'delete':
|
||||
_delete_interest_group(name)
|
||||
elif op == 'reorder':
|
||||
order = [x.strip() for x in order_raw.split(',') if x.strip()]
|
||||
_reorder_interest_groups(order)
|
||||
elif op == 'list':
|
||||
pass # 순수 조회 — 관리 모달 초기 로드용
|
||||
else:
|
||||
raise ValueError(f'unknown op: {op}')
|
||||
if op != 'list':
|
||||
_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
|
||||
reg = _load_interest_groups()
|
||||
counts: dict = {}
|
||||
for names in reg.get('members', {}).values():
|
||||
for n in names:
|
||||
counts[n] = counts.get(n, 0) + 1
|
||||
sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} op={op} name={name!r} → ok\n')
|
||||
sys.stdout.flush()
|
||||
if wants_json:
|
||||
self._send_json(200, {'ok': True, 'groups': reg.get('groups', []), 'counts': counts})
|
||||
else:
|
||||
self.send_response(303)
|
||||
self.send_header('Location', '/#tab-interests')
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
if self.path in in_actions:
|
||||
action = in_actions[self.path]
|
||||
# add는 stock 또는 code 둘 중 하나면 OK — _apply_interests_action 안에서 검증.
|
||||
|
||||
@@ -171,6 +171,7 @@ def collect_snapshot(code: str, holding: dict | None = None) -> dict:
|
||||
sma5 = sma(closes, 5)
|
||||
sma20 = sma(closes, 20)
|
||||
sma60 = sma(closes, 60)
|
||||
sma120 = sma(closes, 120)
|
||||
|
||||
flow = kc.get_investor_flow(code, days=20)
|
||||
flow_summary = _summarize_flow(flow)
|
||||
@@ -191,6 +192,7 @@ def collect_snapshot(code: str, holding: dict | None = None) -> dict:
|
||||
'sma5': sma5,
|
||||
'sma20': sma20,
|
||||
'sma60': sma60,
|
||||
'sma120': sma120,
|
||||
'flow': flow,
|
||||
'flow_summary': flow_summary,
|
||||
'holding': holding,
|
||||
@@ -583,7 +585,7 @@ def remove_peer(stock_code: str, peer_code: str) -> bool:
|
||||
# ============================================================================
|
||||
|
||||
CHART_W = 1000
|
||||
CHART_H = 720 # crosshair 박스 영역(상단) + X축 날짜 라벨 영역 확보
|
||||
CHART_H = 660 # X축 날짜 라벨 아래 여백 최소화 (라벨 baseline VOL_BOT+26=642)
|
||||
CHART_PAD_L = 20 # 좌측 라벨 없음 (가격 라벨 우측으로 이동)
|
||||
CHART_PAD_R = 140 # 우측 가격·거래량 라벨 영역 (28px 폰트 = 6자리+쉼표 약 140px)
|
||||
CHART_PAD_T = 60 # SMA legend 영역 (y=4~50). OHLC 팝업은 차트 안 floating tooltip.
|
||||
@@ -619,12 +621,13 @@ def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None
|
||||
sma5 = (snap.get('sma5') or [])[-n_take:]
|
||||
sma20 = (snap.get('sma20') or [])[-n_take:]
|
||||
sma60 = (snap.get('sma60') or [])[-n_take:]
|
||||
sma120 = (snap.get('sma120') or [])[-n_take:]
|
||||
|
||||
# ---- 스케일 ----
|
||||
highs = [c['high'] for c in candles]
|
||||
lows = [c['low'] for c in candles]
|
||||
# SMA 값도 가격축 범위에 포함 (선이 잘리지 않도록)
|
||||
sma_vals = [v for v in (sma5 + sma20 + sma60) if v is not None]
|
||||
sma_vals = [v for v in (sma5 + sma20 + sma60 + sma120) if v is not None]
|
||||
y_max = max(max(highs), max(sma_vals) if sma_vals else 0)
|
||||
y_min = min(min(lows), min(sma_vals) if sma_vals else float('inf'))
|
||||
if ref_price:
|
||||
@@ -757,6 +760,7 @@ def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None
|
||||
parts.append(sma_path(sma5, '#fbbf24', 'SMA5')) # 노랑
|
||||
parts.append(sma_path(sma20, '#34d399', 'SMA20')) # 초록
|
||||
parts.append(sma_path(sma60, '#a78bfa', 'SMA60')) # 보라
|
||||
parts.append(sma_path(sma120, '#22d3ee', 'SMA120')) # 청록
|
||||
|
||||
# ---- 전일종가 기준선 + 대비 등락률 (ref_price 지정 시 — 분봉) ----
|
||||
if ref_price:
|
||||
@@ -772,16 +776,16 @@ def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None
|
||||
|
||||
# SMA 범례
|
||||
legend_y = PRICE_TOP + 14
|
||||
legend_items = [('SMA5', '#fbbf24'), ('SMA20', '#34d399'), ('SMA60', '#a78bfa')]
|
||||
legend_items = [('5일', '#fbbf24'), ('20일', '#34d399'), ('60일', '#a78bfa'), ('120일', '#22d3ee')]
|
||||
lx = CHART_PAD_L + 8
|
||||
for name, color in legend_items:
|
||||
parts.append(
|
||||
f'<rect x="{lx}" y="{legend_y-8}" width="10" height="3" fill="{color}"/>'
|
||||
)
|
||||
parts.append(
|
||||
f'<text x="{lx+14}" y="{legend_y-2}" fill="#cfd5df">{name}</text>'
|
||||
f'<text x="{lx+14}" y="{legend_y-2}" fill="#cfd5df" font-size="20">{name}</text>'
|
||||
)
|
||||
lx += 70
|
||||
lx += 96
|
||||
|
||||
# ---- 거래량 패널 배경 ----
|
||||
parts.append(
|
||||
@@ -836,6 +840,38 @@ def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None
|
||||
f'{_fmt_date(candles[i]["date"])}</text>'
|
||||
)
|
||||
|
||||
# ---- 구간 고점선 + 현재가 태그 (고점·현재가 강조) ----
|
||||
# 고점: 표시 구간 내 최고 고가에 노란 점선 + 우측 '고 N' 라벨.
|
||||
hi_price = max(highs)
|
||||
hy = price_y(hi_price)
|
||||
parts.append(
|
||||
f'<line x1="{CHART_PAD_L}" y1="{hy:.1f}" x2="{CHART_W-CHART_PAD_R}" y2="{hy:.1f}" '
|
||||
f'stroke="#fbbf24" stroke-width="1" stroke-dasharray="4 4" opacity="0.55"/>'
|
||||
)
|
||||
# 고가 금액 — 가격패널 좌하단 안 (구간별 최고 고가). 상단 우측 대신 아래로 이동.
|
||||
parts.append(
|
||||
f'<text x="{CHART_PAD_L + 8}" y="{PRICE_BOT - 10:.1f}" text-anchor="start" fill="#fbbf24" '
|
||||
f'font-size="26" font-weight="600">고가 {int(round(hi_price)):,}</text>'
|
||||
)
|
||||
# 현재가(마지막 종가): 얇은 기준선 + 우측 축 강조 태그(상승=빨강/하락=파랑).
|
||||
cur_price = candles[-1]['close']
|
||||
cy = price_y(cur_price)
|
||||
last_up = candles[-1]['close'] >= candles[-1]['open']
|
||||
cur_color = '#ef4444' if last_up else '#3b82f6'
|
||||
parts.append(
|
||||
f'<line x1="{CHART_PAD_L}" y1="{cy:.1f}" x2="{CHART_W-CHART_PAD_R}" y2="{cy:.1f}" '
|
||||
f'stroke="{cur_color}" stroke-width="1" stroke-dasharray="2 3" opacity="0.5"/>'
|
||||
)
|
||||
tag_cy = min(max(cy, PRICE_TOP + 15), PRICE_BOT - 15)
|
||||
parts.append(
|
||||
f'<rect x="{CHART_W - CHART_PAD_R + 2}" y="{tag_cy-15:.1f}" width="{CHART_PAD_R-6}" height="30" '
|
||||
f'fill="{cur_color}" rx="3"/>'
|
||||
)
|
||||
parts.append(
|
||||
f'<text x="{CHART_W - CHART_PAD_R + 8}" y="{tag_cy+8:.1f}" text-anchor="start" fill="#fff" '
|
||||
f'font-size="26" font-weight="700">{int(round(cur_price)):,}</text>'
|
||||
)
|
||||
|
||||
parts.append('</svg>')
|
||||
return ''.join(parts)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user