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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-07-04 02:00:03 +09:00
parent dd1ad7d964
commit 008ce8be65
173 changed files with 4164 additions and 3641 deletions
+641 -31
View File
@@ -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)