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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-07-29 02:00:02 +09:00
parent f3a5858597
commit c5dd2f98a8
173 changed files with 2883 additions and 5481 deletions
+574 -67
View File
@@ -44,6 +44,7 @@ STOCK_TAGS = WORKSPACE / 'state' / 'behive_stock_tags.json'
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'
ALERTS_STATE = WORKSPACE / 'state' / 'watchlist_alerts.json'
HOLIDAYS_FILE = WORKSPACE / 'state' / 'market_holidays.json'
SNAPSHOT_FILE = WORKSPACE / 'state' / 'portfolio_daily_snapshot.json'
@@ -2047,6 +2048,100 @@ def _set_stock_note(code: str | None, name: str | None, text: str) -> None:
tmp.replace(STOCK_NOTES)
def _holdeval_lock():
"""HOLDEVAL_SAVED 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
import fcntl as _fcntl
from contextlib import contextmanager as _cm
@_cm
def _ctx():
lock_path = HOLDEVAL_SAVED.with_suffix(HOLDEVAL_SAVED.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_holdeval_saved() -> list:
"""보유 평가 계산기 저장 목록. [{id, owner, code, name, avg, qty}] 리스트."""
if not HOLDEVAL_SAVED.exists():
return []
try:
d = json.loads(HOLDEVAL_SAVED.read_text())
return d if isinstance(d, list) else []
except Exception:
return []
def _save_holdeval_saved(items: list) -> None:
HOLDEVAL_SAVED.parent.mkdir(parents=True, exist_ok=True)
tmp = HOLDEVAL_SAVED.with_suffix('.json.tmp')
tmp.write_text(json.dumps(items, ensure_ascii=False, indent=2))
tmp.replace(HOLDEVAL_SAVED)
def _add_holdeval_saved(owner: str, code: str, name: str, avg: float, qty: float) -> list:
"""저장 항목 추가. code·avg·qty 필수. 새 id 부여 후 갱신된 목록 반환."""
import uuid
owner = (owner or '').strip()[:40]
code = ''.join(ch for ch in (code or '').strip() if ch.isalnum())
name = (name or '').strip()[:40]
if not code:
raise ValueError('종목을 먼저 검색·선택하세요')
if avg <= 0 or qty <= 0:
raise ValueError('평단가·주수는 0보다 커야 합니다')
with _holdeval_lock():
items = _load_holdeval_saved()
items.append({'id': uuid.uuid4().hex[:8], 'owner': owner, 'code': code,
'name': name, 'avg': avg, 'qty': qty})
_save_holdeval_saved(items)
return items
def _delete_holdeval_saved(entry_id: str) -> list:
"""id로 저장 항목 삭제. 갱신된 목록 반환."""
with _holdeval_lock():
items = [it for it in _load_holdeval_saved() if it.get('id') != entry_id]
_save_holdeval_saved(items)
return items
def _update_holdeval_saved(entry_id: str, owner: str, avg: float, qty: float,
code: str = '', name: str = '') -> list:
"""id로 저장 항목 수정 (물타기·불타기 후 평단가·주수 갱신). owner·avg·qty 필수.
code·name은 넘어오면 교체, 비면 기존 유지. 갱신된 목록 반환."""
owner = (owner or '').strip()[:40]
code = ''.join(ch for ch in (code or '').strip() if ch.isalnum())
name = (name or '').strip()[:40]
if avg <= 0 or qty <= 0:
raise ValueError('평단가·주수는 0보다 커야 합니다')
with _holdeval_lock():
items = _load_holdeval_saved()
found = False
for it in items:
if it.get('id') == entry_id:
it['owner'] = owner
it['avg'] = avg
it['qty'] = qty
if code:
it['code'] = code
if name:
it['name'] = name
found = True
break
if not found:
raise ValueError('항목을 찾을 수 없습니다')
_save_holdeval_saved(items)
return items
def _note_button_html(code: str, name: str) -> str:
"""detail actions의 '메모' 버튼. 클릭 시 note-modal 열리며 현재 메모가 prefill.
메모가 있으면 표시 + has-note 클래스로 강조."""
@@ -5873,6 +5968,20 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
.adr-tag.down { background: rgba(74,140,240,0.16); color: #8db4f4; border: 1px solid rgba(74,140,240,0.3); }
.adr-tag.flat { background: #1a1d24; color: #8b8f9a; border: 1px solid #2a2e36; }
.adr-sub { margin-top: 2px; font-size: 10px; line-height: 1.1; color: #8b8f9a; }
/* ADR 기본정보 시장별 20/오늘 ADR + 종목수. market-table 기반, 시장 컬럼만 좌측정렬. */
table.adr-info-table { margin: 4px 0 2px; }
table.adr-info-table td { padding: 5px 0 5px 8px; vertical-align: middle; }
table.adr-info-table th { padding: 0 0 3px 8px; }
table.adr-info-table td.adr-mk, table.adr-info-table th:first-child { text-align: left; padding-left: 0; color: #c9ccd3; }
table.adr-info-table td.adr-mk { font-weight: 600; }
table.adr-info-table td b { font-size: 14px; }
table.adr-info-table .adr-tag { margin-left: 0; margin-right: 5px; }
table.adr-info-table td.adr-breakdown { font-size: 11px; color: #8b8f9a; white-space: nowrap; }
.adr-cnt-sub { font-size: 9px; color: #6b7280; font-weight: 400; }
@media (max-width: 600px) {
table.adr-info-table td.adr-breakdown { font-size: 10px; }
table.adr-info-table td b { font-size: 13px; }
}
@media (max-width: 600px) {
.market-card { margin: 10px 10px 6px; padding: 12px 14px; }
table.market-table td { font-size: 12px; }
@@ -6066,6 +6175,39 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
.calc-sym-row .order-symbol-select { margin-top: 5px; max-width: none; }
.calc-sym-status { margin-top: 6px; font-size: 12px; color: #8b8f9a; min-height: 1em; }
.calc-sym-status:empty { display: none; }
.calc-price-row { display: flex; gap: 6px; align-items: center; }
.calc-price-row input { flex: 1 1 auto; min-width: 0; }
.calc-curbtn { flex: 0 0 auto; padding: 0 10px; height: 34px; border: 1px solid #2f6df0; border-radius: 8px; background: #1a2436; color: #7fb0ff; font-size: 12px; font-weight: 600; cursor: pointer; white-space: nowrap; }
.calc-curbtn:active { background: #23324d; }
.calc-sub { font-size: 11px; color: #6b7280; font-weight: 400; }
.he-save-row { margin-top: 12px; }
.he-save-btn { width: 100%; padding: 10px; border: 1px solid rgba(125,211,168,0.35); border-radius: 8px; background: rgba(125,211,168,0.1); color: #7dd3a8; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; }
.he-save-btn:active { background: rgba(125,211,168,0.2); }
.he-saved-wrap { margin-top: 16px; }
.he-saved-title { font-size: 12px; color: #8b8f9a; margin-bottom: 6px; }
.he-saved-list { display: flex; flex-direction: column; gap: 6px; }
.he-saved-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: #14171f; border: 1px solid #232838; border-radius: 8px; }
.he-saved-pick { flex: 1 1 auto; min-width: 0; text-align: left; background: none; border: none; color: #e8eaed; font-size: 13px; cursor: pointer; font-family: inherit; padding: 0; }
.he-saved-pick .he-saved-meta { color: #8b8f9a; font-size: 12px; }
.he-saved-edit { flex: 0 0 auto; background: none; border: none; color: #6b7280; font-size: 14px; cursor: pointer; padding: 0 4px; }
.he-saved-edit:hover { color: #7dd3a8; }
.he-saved-del { flex: 0 0 auto; background: none; border: none; color: #6b7280; font-size: 16px; cursor: pointer; padding: 0 4px; }
.he-saved-del:hover { color: #ff4d5e; }
.he-saved-pick.active { color: #ffcc33; }
.he-add-row { margin-top: 10px; }
.he-add-btn { width: 100%; padding: 10px; border: 1px dashed #3a4152; border-radius: 8px; background: transparent; color: #8b8f9a; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; }
.he-add-btn:active { background: #14171f; }
.he-result-headrow { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
.he-result-head { font-size: 12px; color: #8b8f9a; min-width: 0; }
.he-refresh-btn { flex: 0 0 auto; }
.he-refresh-btn.hidden { display: none; }
.he-merge-wrap { margin-top: 12px; padding: 10px; border: 1px dashed #2f6df0; border-radius: 8px; background: rgba(47,109,240,0.06); }
.he-merge-wrap.hidden { display: none; }
.he-merge-title { font-size: 12px; color: #8b8f9a; margin-bottom: 8px; }
.he-detail-sep { height: 1px; background: #232838; margin: 8px 0; }
.calc-line.he-detail { padding: 2px 0; }
.calc-line.he-detail .calc-lbl { font-size: 12px; }
.calc-line.he-detail .calc-val { font-size: 14px; font-weight: 600; color: #c9ccd3; }
/* 차트 컨트롤 자산정보 상단의 공통 기간·단위 select 묶음. */
.chart-controls { display: flex; flex-direction: column; gap: 6px; margin: 10px 14px 4px; }
@@ -6246,6 +6388,14 @@ input[data-order-qty].qty-over { color: #ff8a95; }
.order-toast.show { opacity: 1; }
.order-toast.success { background: #142a18; border-color: #1f5a28; color: #8aff95; }
.order-toast.info { background: #14171f; border-color: #1f2330; color: #c9ccd3; }
/* iOS Safari는 포커스된 입력요소 font-size<16px면 자동 확대() 터치기기에서 컨트롤 최소 16px 보장.
blanket 규칙은 명시 font-size 없는 입력(UA 기본치) 잡고, 아래 목록이 16px 미만 클래스 규칙을 소스순서로 덮는다.
.order-pin-row input(22px) 의도적 값은 특이도가 높아 불변, 데스크톱은 pointer:coarse 밖이라 그대로. */
@media (pointer: coarse) {
input, select, textarea { font-size: 16px; }
.igrp-select, .group-name-input, .info-search-input, .info-symbols-select,
.order-symbol-select, .chart-controls .nw-select { font-size: 16px; }
}
'''
@@ -6453,78 +6603,57 @@ def _compute_adr_ma_series(rows: list[dict], window: int = ADR_MA_WINDOW) -> lis
return series
def _render_adr_basic_block(rows: list[dict] | None) -> str:
"""ADR 기본정보 — 20일 정통 ADR + 침체/과열 배지. ADR 추세 카드 기본(접힘) 상태에 항상 노출.
배지·색은 20 기준(80 침체·120 과열). 20 누적 미만이면 배지 없이 (n/20) 표기."""
def _render_adr_info_table(rows: list[dict] | None) -> str:
"""ADR 기본정보 표 — 시장(코스피/코스닥)별 20일 ADR·오늘 ADR·종목수(상승/하락/보합).
20·오늘 각각 80 침체·120 과열 배지. 20 누적 미만이면 배지 없이 (n/20). 추세 그래프 상시 노출."""
if not rows:
return (
'<div class="adr-today-block">'
+ '<div class="market-section-hint">시장 지표 미수신</div>'
+ '</div>'
)
items: list[str] = []
body: list[str] = []
for r in rows:
# 20일 정통 ADR (배지·색은 20일 값 기준)
ma, count, _ = _compute_adr_ma_latest(r['symbol'], r)
note = ''
if ma is None:
ma_str, ma_cls, tag_html = '', 'neutral', ''
ma_html = '<span class="neutral">—</span>'
elif count >= ADR_MA_WINDOW:
ma_str = f'{ma:.1f}'
cls, tag = _adr_class(ma)
ma_cls = cls if cls in ('up', 'down') else 'neutral'
tag_html = f' <span class="adr-tag {cls}">{tag}</span>' if tag else ''
tag_html = f'<span class="adr-tag {cls}">{tag}</span>' if tag else ''
ma_html = f'{tag_html}<b class="{ma_cls}">{ma:.1f}</b>'
else:
ma_str, ma_cls, tag_html = f'{ma:.1f}', 'neutral', ''
note = f' <span class="muted small">({count}/{ADR_MA_WINDOW})</span>'
items.append(
f'<span class="adr-today-item">'
f'<span class="adr-today-mk">{html.escape(r["label"])}</span> '
f'<span class="adr-when">20일</span><b class="{ma_cls}">{ma_str}</b>{tag_html}{note}'
f'</span>'
)
return (
'<div class="adr-today-block">'
+ '<span class="adr-today-when">ADR</span>'
+ ''.join(items)
+ '</div>'
)
def _render_adr_today_detail(rows: list[dict] | None) -> str:
"""오늘 raw ADR + 침체/과열 배지 + 종목수(상승/하락/보합). 추세 그래프(펼침) 안에 표시.
색은 방향만(100 초과=빨강/미만=파랑). 배지는 오늘 raw ADR 기준(80 침체·120 과열)."""
if not rows:
return ''
items: list[str] = []
for r in rows:
ma_html = f'<b class="neutral">{ma:.1f}</b> <span class="muted small">({count}/{ADR_MA_WINDOW})</span>'
# 오늘 raw ADR (색은 방향만: 100 초과=빨강/미만=파랑)
raw_adr = r.get('adr')
if raw_adr is None:
raw_str, raw_cls, tag_html = '', 'neutral', ''
today_html = '<span class="neutral">—</span>'
else:
raw_str = f'{raw_adr:.1f}'
raw_cls = 'up' if raw_adr > 100 else ('down' if raw_adr < 100 else 'neutral')
tag_cls, tag = _adr_class(raw_adr)
tag_html = f' <span class="adr-tag {tag_cls}">{tag}</span>' if tag else ''
tag_html = f'<span class="adr-tag {tag_cls}">{tag}</span>' if tag else ''
today_html = f'{tag_html}<b class="{raw_cls}">{raw_adr:.1f}</b>'
# 종목수 (상승↑/하락↓/보합→)
rise, fall, steady = r.get('rise'), r.get('fall'), r.get('steady')
bd_html = ''
if rise is not None and fall is not None and steady is not None:
bd_html = (
'<span class="adr-today-breakdown">'
f'<span class="up">{rise:,}↑</span> / <span class="down">{fall:,}↓</span> / <span class="neutral">{steady:,}→</span>'
'</span>'
f'<span class="up">{rise:,}↑</span> / '
f'<span class="down">{fall:,}↓</span> / '
f'<span class="neutral">{steady:,}→</span>'
)
items.append(
f'<span class="adr-today-item">'
f'<span class="adr-today-mk">{html.escape(r["label"])}</span> '
f'<span class="adr-when">오늘</span><b class="{raw_cls}">{raw_str}</b>{tag_html}'
f'{bd_html}'
f'</span>'
else:
bd_html = '<span class="neutral">—</span>'
body.append(
f'<tr><td class="adr-mk">{html.escape(r["label"])}</td>'
f'<td>{ma_html}</td><td>{today_html}</td>'
f'<td class="adr-breakdown">{bd_html}</td></tr>'
)
return (
'<div class="adr-today-block">'
+ '<span class="adr-today-when">오늘</span>'
+ ''.join(items)
+ '</div>'
'<table class="market-table adr-info-table">'
'<thead><tr><th>시장</th><th>20일</th><th>오늘</th>'
'<th>종목수 <span class="adr-cnt-sub">↑/↓/→</span></th></tr></thead>'
'<tbody>' + ''.join(body) + '</tbody></table>'
)
@@ -6799,9 +6928,8 @@ def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
ma_series = ma_series[-days:]
market_pts[sym] = ma_series
# 기본(접힘)엔 20일 ADR(+배지). 오늘 raw·종목수는 추세 그래프(펼침) 안으로 이관.
basic_block = _render_adr_basic_block(live_rows)
today_detail = _render_adr_today_detail(live_rows)
# 20일 ADR·오늘 ADR·종목수를 표(그래프 밖 상시)로 표시.
adr_table = _render_adr_info_table(live_rows)
# 그래프 펼침 토글 버튼 — 클릭 시 .adr-graph-wrap hidden 토글(순수 클라이언트, localStorage 보존).
graph_toggle = (
'<button type="button" class="adr-graph-toggle" data-adr-graph-toggle aria-expanded="false">'
@@ -6811,17 +6939,14 @@ def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
# 두 시장 합쳐 점이 하나도 없으면 누적 부족 안내.
all_dates = sorted({d for pts in market_pts.values() for d, _, _ in pts if d})
if not all_dates:
sel_label = next((lbl for lbl, d in ADR_TREND_PRESETS if d == days), '맞춤')
hint = (f'{ADR_MA_WINDOW}일 누적 중 ({raw_count_max}/{ADR_MA_WINDOW}) — '
f'매 영업일 21:00 raw ADR 적재, {ADR_MA_WINDOW}일 채워지면 이평선 표시')
return (
'<div class="market-card adr-trend-card">'
f'<div class="market-card-title">ADR 추세 <span class="market-card-sub">'
+ html.escape(sel_label) + '</span></div>'
+ basic_block
'<div class="market-card-title">ADR 추세</div>'
+ adr_table
+ graph_toggle
+ '<div class="adr-graph-wrap" data-adr-graph hidden>'
+ today_detail
+ f'<div class="market-section-hint">{html.escape(hint)}</div>'
+ _render_adr_trend_controls(days)
+ '</div>'
@@ -6954,7 +7079,6 @@ def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
f'fill="#6b7280" font-size="8.5" opacity="0.7">{html.escape(last_dt[5:])}</text>'
)
sel_label = next((lbl for lbl, d in ADR_TREND_PRESETS if d == days), '맞춤')
chart_row = (
f'<div class="adr-trend-row adr-combo-row" data-adr-pts="{pts_json}" data-adr-vb="{W:.0f} {H:.0f}">'
f'<svg class="adr-trend-svg" viewBox="0 0 {W:.0f} {H:.0f}" preserveAspectRatio="none" role="img" aria-label="코스피·코스닥 ADR 추세">'
@@ -6972,12 +7096,11 @@ def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
accum_note = f' · 누적 {raw_count_max}/{ADR_MA_WINDOW}' if raw_count_max < ADR_MA_WINDOW else ''
return (
'<div class="market-card adr-trend-card">'
f'<div class="market-card-title">ADR 추세 <span class="market-card-sub">'
+ html.escape(sel_label) + accum_note + '</span></div>'
+ basic_block
f'<div class="market-card-title">ADR 추세<span class="market-card-sub">'
+ accum_note + '</span></div>'
+ adr_table
+ graph_toggle
+ '<div class="adr-graph-wrap" data-adr-graph hidden>'
+ today_detail
+ f'<div class="market-section-hint">파란선=80 · 회색선=100(균형) · 빨간선=120 · *=오늘(라이브 포함)</div>'
+ _render_adr_trend_controls(days)
+ legend_html
@@ -7284,6 +7407,7 @@ def _render_calc_modal() -> str:
'<div class="add-form-grid">'
'<label>기준가 <input type="number" inputmode="decimal" step="any" data-calc-base placeholder="예: 10000" autocomplete="off"></label>'
'<label>현재가 <input type="number" inputmode="decimal" step="any" data-calc-cur placeholder="예: 11250" autocomplete="off"></label>'
'<label>주수 <span class="calc-sub">(선택 · 수익금 계산)</span><input type="number" inputmode="numeric" step="any" data-calc-qty placeholder="예: 100" autocomplete="off"></label>'
'</div>'
'<label class="calc-sym-row">종목에서 현재가 가져오기 '
'<select class="order-symbol-select" data-calc-symbol aria-label="관심·보유·감시 종목에서 현재가 불러오기">'
@@ -7293,6 +7417,7 @@ def _render_calc_modal() -> str:
'<div class="calc-result" data-calc-result>'
'<div class="calc-line"><span class="calc-lbl">등락률</span><span class="calc-val" data-calc-pct>—</span></div>'
'<div class="calc-line"><span class="calc-lbl">차이</span><span class="calc-val" data-calc-diff>—</span></div>'
'<div class="calc-line"><span class="calc-lbl">수익금 <span class="calc-sub" data-calc-profit-sub></span></span><span class="calc-val" data-calc-profit>—</span></div>'
'<div class="calc-line"><span class="calc-lbl">기준가 회복 필요</span><span class="calc-val" data-calc-recover>—</span></div>'
'<div class="calc-hint" data-calc-hint>기준가·현재가를 입력하세요.</div>'
'</div>'
@@ -7327,13 +7452,15 @@ def _render_avg_calc_modal() -> str:
'<label>보유수량 <input type="number" inputmode="numeric" step="any" data-avg-hqty placeholder="예: 100" autocomplete="off"></label>'
'<label>현재평단가 <input type="number" inputmode="decimal" step="any" data-avg-havg placeholder="예: 70000" autocomplete="off"></label>'
'<label>추가매수 주수 <input type="number" inputmode="numeric" step="any" data-avg-aqty placeholder="예: 50" autocomplete="off"></label>'
'<label>추가매수가 <input type="number" inputmode="decimal" step="any" data-avg-aprice placeholder="예: 60000" autocomplete="off"></label>'
'<label>추가매수가 <span class="calc-price-row"><input type="number" inputmode="decimal" step="any" data-avg-aprice placeholder="예: 60000" autocomplete="off"><button type="button" class="calc-curbtn" data-avg-curprice>현재가</button></span></label>'
'<label>추가매수 금액 <input type="number" inputmode="numeric" step="any" data-avg-aamt placeholder="예: 3000000" autocomplete="off"></label>'
'</div>'
'<div class="calc-result" data-avg-result>'
'<div class="calc-line"><span class="calc-lbl">실 매수금액 <span class="calc-sub" data-avg-addqty></span></span><span class="calc-val" data-avg-addcost>—</span></div>'
'<div class="calc-line"><span class="calc-lbl">새 평단가</span><span class="calc-val" data-avg-newavg>—</span></div>'
'<div class="calc-line"><span class="calc-lbl">총 수량</span><span class="calc-val" data-avg-totqty>—</span></div>'
'<div class="calc-line"><span class="calc-lbl">총 매입금</span><span class="calc-val" data-avg-totcost>—</span></div>'
'<div class="calc-hint" data-avg-hint>보유수량·현재평단가·추가매수 주수·추가매수가를 입력하세요.</div>'
'<div class="calc-hint" data-avg-hint>보유수량·현재평단가·추가매수 주수(또는 금액)·추가매수가를 입력하세요.</div>'
'</div>'
'</div>'
'<div class="modal-actions">'
@@ -7344,6 +7471,87 @@ def _render_avg_calc_modal() -> str:
)
def _render_holdeval_modal() -> str:
"""보유 평가 계산기 모달. ☰ 옵션 팝업의 '보유 평가 계산기' 항목이 연다.
저장된 목록을 먼저 보여주고, 항목을 누르면 현재가를 조회해 등락률·평가손익·매입/평가금액을 표시.
하단 '추가' 버튼이 입력 팝업(holdeval-add-modal) 연다. /api/holdeval/* + /api/quote_book 재사용."""
return (
'<div id="holdeval-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="holdeval-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="holdeval-modal-title">보유 평가 계산기</div>'
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
'</div>'
'<div class="add-form">'
'<div class="he-saved-title">저장된 목록</div>'
'<div class="he-saved-list" data-he-saved><div class="muted small">불러오는 중…</div></div>'
'<div class="he-add-row"><button type="button" class="he-add-btn" data-modal-open="holdeval-add-modal"> 추가</button></div>'
'<div class="calc-result" data-he-result>'
'<div class="he-result-headrow"><span class="he-result-head" data-he-result-head>목록에서 항목을 누르면 현재 상태가 표시됩니다.</span>'
'<button type="button" class="calc-curbtn he-refresh-btn hidden" data-he-refresh>갱신</button></div>'
'<div class="calc-line"><span class="calc-lbl">등락률</span><span class="calc-val" data-he-pct>—</span></div>'
'<div class="calc-line"><span class="calc-lbl">평가손익 <span class="calc-sub" data-he-pl-sub></span></span><span class="calc-val" data-he-pl>—</span></div>'
'<div class="he-detail-sep"></div>'
'<div class="calc-line he-detail"><span class="calc-lbl">종목</span><span class="calc-val" data-he-name>—</span></div>'
'<div class="calc-line he-detail"><span class="calc-lbl">현재가</span><span class="calc-val" data-he-price>—</span></div>'
'<div class="calc-line he-detail"><span class="calc-lbl">평단가</span><span class="calc-val" data-he-avgline>—</span></div>'
'<div class="calc-line he-detail"><span class="calc-lbl">주수</span><span class="calc-val" data-he-qtyline>—</span></div>'
'<div class="calc-line he-detail"><span class="calc-lbl">매입금액</span><span class="calc-val" data-he-cost>—</span></div>'
'<div class="calc-line he-detail"><span class="calc-lbl">평가금액</span><span class="calc-val" data-he-value>—</span></div>'
'</div>'
'</div>'
'<div class="modal-actions">'
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
'</div>'
'</div>'
'</div>'
)
def _render_holdeval_add_modal() -> str:
"""보유 평가 계산기 '추가' 팝업(중첩 modal-top). 이름·종목검색·평단가·주수 입력 후 저장하면
holdeval-modal 목록에 추가된다. 현재가는 저장하지 않고 조회 시점에 라이브로 가져오므로 여기선 받는다."""
return (
'<div id="holdeval-add-modal" class="modal modal-top hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="holdeval-add-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="holdeval-add-modal-title">항목 추가</div>'
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
'</div>'
'<div class="add-form">'
'<label class="calc-sym-row">이름 <span class="calc-sub">(예: 방효원 · 선택)</span>'
'<div class="info-search-row"><input type="text" class="info-search-input" data-he-owner placeholder="예: 방효원" autocomplete="off" maxlength="40"></div></label>'
'<label class="calc-sym-row">종목 검색 <span class="calc-sub">(이름 또는 6자리 코드)</span>'
'<div class="info-search-row">'
'<input type="text" class="info-search-input" data-he-search placeholder="예: 삼성전자 / 005930" autocomplete="off" enterkeyhint="search">'
'<button type="button" class="info-search-btn" data-he-search-btn>조회</button>'
'</div></label>'
'<div class="info-search-results hidden" data-he-results></div>'
'<div class="calc-sym-status" data-he-sym-status></div>'
'<div class="add-form-grid">'
'<label>평단가 <input type="number" inputmode="decimal" step="any" data-he-avg placeholder="예: 60000" autocomplete="off"></label>'
'<label>주수 <input type="number" inputmode="numeric" step="any" data-he-qty placeholder="예: 100" autocomplete="off"></label>'
'</div>'
'<div class="he-merge-wrap hidden" data-he-merge-wrap>'
'<div class="he-merge-title">추가 매수 합산 <span class="calc-sub">(물타기·불타기 — 입력 시 위 평단가·주수 자동 갱신)</span></div>'
'<div class="add-form-grid">'
'<label>추가 단가 <input type="number" inputmode="decimal" step="any" data-he-addprice placeholder="예: 55000" autocomplete="off"></label>'
'<label>추가 수량 <input type="number" inputmode="numeric" step="any" data-he-addqty placeholder="예: 20" autocomplete="off"></label>'
'</div>'
'<div class="calc-hint" data-he-merge-hint></div>'
'</div>'
'<div class="he-save-row"><button type="button" class="he-save-btn" data-he-save-btn>💾 저장</button></div>'
'</div>'
'<div class="modal-actions">'
'<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
'</div>'
'</div>'
'</div>'
)
def _render_group_assign_modal() -> str:
"""관심종목의 그룹 소속 편집 모달. 어느 관심종목 행이든 '그룹' 버튼이 동일하게 연다.
체크박스는 JS가 data-group-all 렌더·data-group-current 체크. 그룹 인라인 추가 가능."""
@@ -9248,14 +9456,18 @@ def render_html() -> str:
'function recalc(){'
'var base=num(document.querySelector("[data-calc-base]"));'
'var cur=num(document.querySelector("[data-calc-cur]"));'
'var qty=num(document.querySelector("[data-calc-qty]"));'
'var pctEl=document.querySelector("[data-calc-pct]");'
'var diffEl=document.querySelector("[data-calc-diff]");'
'var profEl=document.querySelector("[data-calc-profit]");'
'var profSub=document.querySelector("[data-calc-profit-sub]");'
'var recEl=document.querySelector("[data-calc-recover]");'
'var hintEl=document.querySelector("[data-calc-hint]");'
'if(!pctEl||!diffEl)return;'
'if(isNaN(base)||isNaN(cur)||base===0){'
'pctEl.textContent="";pctEl.style.color=NEU;'
'diffEl.textContent="";diffEl.style.color=NEU;'
'if(profEl){profEl.textContent="";profEl.style.color=NEU;}if(profSub)profSub.textContent="";'
'if(recEl){recEl.textContent="";recEl.style.color=NEU;}'
'if(hintEl)hintEl.textContent=(!isNaN(base)&&base===0)?"기준가는 0이 될 수 없어요.":"기준가·현재가를 입력하세요.";'
'return;'
@@ -9267,6 +9479,13 @@ def render_html() -> str:
'var dsign=diff>0?"+":(diff<0?"":"");'
'pctEl.textContent=psign+Math.abs(pct).toFixed(2)+"%";pctEl.style.color=col;'
'diffEl.textContent=dsign+Math.abs(diff).toLocaleString()+"";diffEl.style.color=col;'
'if(profEl){'
'if(isNaN(qty)||qty<=0){profEl.textContent="";profEl.style.color=NEU;if(profSub)profSub.textContent="주수 입력 시";}'
'else{var prof=diff*qty;var pfsign=prof>0?"+":(prof<0?"":"");'
'profEl.textContent=pfsign+Math.abs(Math.round(prof)).toLocaleString()+"";'
'profEl.style.color=prof>0?UP:(prof<0?DOWN:NEU);'
'if(profSub)profSub.textContent="· "+Math.round(qty).toLocaleString()+"";}'
'}'
'if(recEl){'
'if(cur===0){recEl.textContent="";recEl.style.color=NEU;}'
'else{var rec=(base-cur)/cur*100;'
@@ -9277,7 +9496,7 @@ def render_html() -> str:
'if(hintEl)hintEl.textContent="";'
'}'
'document.addEventListener("input",function(e){'
'if(e.target&&e.target.closest&&e.target.closest("[data-calc-base],[data-calc-cur]"))recalc();'
'if(e.target&&e.target.closest&&e.target.closest("[data-calc-base],[data-calc-cur],[data-calc-qty]"))recalc();'
'});'
# ── 관심·보유·감시 종목에서 현재가 자동 입력 ──
'var symLoaded=false;'
@@ -9345,15 +9564,20 @@ def render_html() -> str:
'var naEl=document.querySelector("[data-avg-newavg]");'
'var tqEl=document.querySelector("[data-avg-totqty]");'
'var tcEl=document.querySelector("[data-avg-totcost]");'
'var acEl=document.querySelector("[data-avg-addcost]");'
'var aqLbl=document.querySelector("[data-avg-addqty]");'
'var hintEl=document.querySelector("[data-avg-hint]");'
'if(!naEl||!tqEl||!tcEl)return;'
'if(isNaN(hq)||isNaN(ha)||isNaN(aq)||isNaN(ap)||(hq+aq)<=0){'
'naEl.textContent="";naEl.style.color="#8b8f9a";'
'tqEl.textContent="";tcEl.textContent="";'
'if(hintEl)hintEl.textContent="보유수량·현재평단가·추가매수 주수·추가매수가를 입력하세요.";'
'if(acEl)acEl.textContent="";if(aqLbl)aqLbl.textContent="";'
'if(hintEl)hintEl.textContent="보유수량·현재평단가·추가매수 주수(또는 금액)·추가매수가를 입력하세요.";'
'return;'
'}'
'var totQty=hq+aq;var totCost=hq*ha+aq*ap;var newAvg=totCost/totQty;'
'if(acEl)acEl.textContent=won(aq*ap);'
'if(aqLbl)aqLbl.textContent=(aq>0)?("· "+Math.round(aq).toLocaleString()+""):"";'
'naEl.textContent=won(newAvg);'
'tqEl.textContent=Math.round(totQty).toLocaleString()+"";'
'tcEl.textContent=won(totCost);'
@@ -9361,8 +9585,38 @@ def render_html() -> str:
'if(hintEl){var d=newAvg-ha;var sign=d<0?"":(d>0?"+":"");'
'hintEl.textContent=(d===0)?"평단 동일":("평단 "+sign+Math.abs(Math.round(d)).toLocaleString()+""+(d<0?"하락":"상승"));}'
'}'
# 추가매수 금액↔주수 양방향 동기화 (추가매수가 기준). 주수가 진실원천 — recalc이 주수로 계산.
# .value 코드 세팅은 input 이벤트를 안 쏘므로 무한루프 없음.
'function syncAdd(src){'
'var apEl=document.querySelector("[data-avg-aprice]");'
'var aqEl=document.querySelector("[data-avg-aqty]");'
'var amEl=document.querySelector("[data-avg-aamt]");'
'if(!apEl||!aqEl||!amEl)return;'
'var ap=num(apEl);'
'if(src==="amt"){var am=num(amEl);if(ap>0&&!isNaN(am))aqEl.value=Math.floor(am/ap);}'
'else{var aq=num(aqEl);if(ap>0&&!isNaN(aq))amEl.value=Math.round(aq*ap);}'
'}'
'document.addEventListener("input",function(e){'
'if(e.target&&e.target.closest&&e.target.closest("[data-avg-hqty],[data-avg-havg],[data-avg-aqty],[data-avg-aprice]"))recalc();'
'if(!e.target||!e.target.closest)return;'
'if(e.target.closest("[data-avg-aamt]")){syncAdd("amt");recalc();return;}'
'if(e.target.closest("[data-avg-aqty]")){syncAdd("qty");recalc();return;}'
'if(e.target.closest("[data-avg-aprice]")){syncAdd("price");recalc();return;}'
'if(e.target.closest("[data-avg-hqty],[data-avg-havg]"))recalc();'
'});'
# 현재가 버튼 — 선택된 보유종목 code로 /api/quote_book 실시간 조회 후 추가매수가에 주입.
'document.addEventListener("click",function(e){'
'var cb=e.target.closest&&e.target.closest("[data-avg-curprice]");if(!cb)return;'
'var sel=document.querySelector("[data-avg-symbol]");'
'var opt=sel&&sel.selectedOptions&&sel.selectedOptions[0];'
'var code=opt&&opt.getAttribute("data-code");'
'var st=document.querySelector("[data-avg-sym-status]");'
'if(!code){if(st){st.textContent="종목을 먼저 선택하세요";st.style.color="#c9a227";}return;}'
'cb.disabled=true;'
'fetch("/api/quote_book?code="+encodeURIComponent(code),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
'var px=d&&d.price;var apEl=document.querySelector("[data-avg-aprice]");'
'if(px>0&&apEl){apEl.value=px;syncAdd("price");recalc();if(st){st.textContent="현재가 "+Number(px).toLocaleString()+"원 적용";st.style.color="#8b8f9a";}}'
'else if(st){st.textContent="현재가 조회 실패";st.style.color="#c9a227";}'
'}).catch(function(){if(st){st.textContent="현재가 조회 실패";st.style.color="#c9a227";}}).then(function(){cb.disabled=false;});'
'});'
# ── 내 계좌 보유종목에서 보유수량·현재평단가 자동 입력 (code별 전 계좌 가중평균) ──
'var hLoaded=false;'
@@ -9376,7 +9630,7 @@ def render_html() -> str:
'items.forEach(function(it,i){'
'var nm=(it.name||it.code).replace(/</g,"&lt;");'
'var lbl=nm+" ("+(it.owner||"")+" "+Number(it.qty).toLocaleString()+"주)";'
'html+=\'<option value="\'+i+\'" data-qty="\'+it.qty+\'" data-avg="\'+it.avg+\'" data-name="\'+(nm+" "+(it.owner||"")).replace(/"/g,"&quot;")+\'">\'+lbl+"</option>";'
'html+=\'<option value="\'+i+\'" data-code="\'+(it.code||"")+\'" data-qty="\'+it.qty+\'" data-avg="\'+it.avg+\'" data-name="\'+(nm+" "+(it.owner||"")).replace(/"/g,"&quot;")+\'">\'+lbl+"</option>";'
'});'
'sel.innerHTML=html;'
'}).catch(function(){hLoaded=false;});'
@@ -9404,6 +9658,188 @@ def render_html() -> str:
'})();</script>'
)
# 보유 평가 계산기 로직 — 임의 종목 검색(현재가 자동) + 평단가·주수 → 등락률·평가손익·매입/평가금액 (순수 클라이언트).
holdeval_script = (
'<script>(function(){'
'var UP="#ff4d5e",DOWN="#4a8cf0",NEU="#8b8f9a";'
'var selCode="",selName="";' # 추가/수정 팝업에서 검색·선택한 종목
'var savedItems=[],curSel="",editId="";' # curSel=결과영역 표시 항목, editId!=""면 수정 모드
'var editBaseAvg=NaN,editBaseQty=NaN;' # 수정 모드 기존 평단/주수 (추가매수 합산의 기준)
'function num(el){if(!el)return NaN;var v=parseFloat(el.value);return isFinite(v)?v:NaN;}'
'function won(n){var s=n<0?"":(n>0?"+":"");return s+Math.abs(Math.round(n)).toLocaleString()+"";}'
'function esc(s){return String(s==null?"":s).replace(/[&<>"]/g,function(c){return{"&":"&amp;","<":"&lt;",">":"&gt;","\\"":"&quot;"}[c];});}'
# ── 결과 영역(메인 모달): 저장항목+현재가로 등락률·손익·매입/평가 표시 ──
'function resultEls(){return{'
'head:document.querySelector("[data-he-result-head]"),refresh:document.querySelector("[data-he-refresh]"),'
'pct:document.querySelector("[data-he-pct]"),pl:document.querySelector("[data-he-pl]"),plSub:document.querySelector("[data-he-pl-sub]"),'
'nameL:document.querySelector("[data-he-name]"),price:document.querySelector("[data-he-price]"),'
'avgL:document.querySelector("[data-he-avgline]"),qtyL:document.querySelector("[data-he-qtyline]"),'
'cost:document.querySelector("[data-he-cost]"),val:document.querySelector("[data-he-value]")};}'
'function clearResult(){curSel="";var E=resultEls();'
'if(E.head)E.head.textContent="목록에서 항목을 누르면 현재 상태가 표시됩니다.";'
'if(E.refresh)E.refresh.classList.add("hidden");'
'if(E.pct){E.pct.textContent="";E.pct.style.color=NEU;}'
'if(E.pl){E.pl.textContent="";E.pl.style.color=NEU;}if(E.plSub)E.plSub.textContent="";'
'if(E.nameL)E.nameL.textContent="";if(E.price)E.price.textContent="";'
'if(E.avgL)E.avgL.textContent="";if(E.qtyL)E.qtyL.textContent="";'
'if(E.cost)E.cost.textContent="";if(E.val)E.val.textContent="";'
'markActive();}'
'function renderResult(it,price){var E=resultEls();if(!E.pct)return;'
'var owner=it.owner?esc(it.owner)+" · ":"";'
'if(E.head)E.head.innerHTML=owner+esc(it.name||it.code);'
'if(E.refresh)E.refresh.classList.remove("hidden");'
'var avg=it.avg,qty=it.qty;var pct=(price-avg)/avg*100;var col=pct>0?UP:(pct<0?DOWN:NEU);var psign=pct>0?"+":(pct<0?"":"");'
'E.pct.textContent=psign+Math.abs(pct).toFixed(2)+"%";E.pct.style.color=col;'
'var cost=avg*qty,val=price*qty,pl=val-cost;'
'E.pl.textContent=won(pl);E.pl.style.color=pl>0?UP:(pl<0?DOWN:NEU);'
'if(E.plSub)E.plSub.textContent="· "+Math.round(qty).toLocaleString()+"";'
'if(E.nameL)E.nameL.textContent=(it.name||it.code)+(it.code?(" ("+it.code+")"):"");'
'if(E.price){E.price.textContent=Number(price).toLocaleString()+"";E.price.style.color=col;}'
'if(E.avgL)E.avgL.textContent=Math.round(avg).toLocaleString()+"";'
'if(E.qtyL)E.qtyL.textContent=Math.round(qty).toLocaleString()+"";'
'E.cost.textContent=Math.round(cost).toLocaleString()+"";'
'E.val.textContent=Math.round(val).toLocaleString()+"";}'
'function fetchResult(it){var E=resultEls();'
'if(E.head)E.head.textContent=(it.name||it.code)+" 현재가 불러오는 중…";'
'fetch("/api/quote_book?code="+encodeURIComponent(it.code),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
'if(curSel!==it.id)return;' # 조회 중 다른 항목 선택되면 무시
'var p=d&&d.price;'
'if(!p||isNaN(p)){if(E.head)E.head.textContent=(it.name||it.code)+" 현재가를 가져오지 못했어요.";return;}'
'renderResult(it,p);'
'}).catch(function(){if(curSel===it.id&&E.head)E.head.textContent="현재가 조회 실패";});}'
'function selectSaved(id){var it=null;for(var i=0;i<savedItems.length;i++){if(savedItems[i].id===id){it=savedItems[i];break;}}'
'if(!it)return;curSel=id;markActive();fetchResult(it);}'
'function markActive(){var picks=document.querySelectorAll("[data-he-pick]");'
'for(var i=0;i<picks.length;i++){picks[i].classList.toggle("active",picks[i].getAttribute("data-he-id")===curSel);}}'
# ── 저장 목록 렌더/로드 ──
'function renderSaved(items){var wrap=document.querySelector("[data-he-saved]");if(!wrap)return;'
'if(!items||!items.length){wrap.innerHTML=\'<div class="muted small">저장된 항목이 없습니다. 아래 추가를 눌러 등록하세요.</div>\';return;}'
'wrap.innerHTML=items.map(function(it){'
'var owner=it.owner?esc(it.owner)+" · ":"";var nm=esc(it.name||it.code);'
'var meta=Math.round(it.avg).toLocaleString()+"원 · "+Math.round(it.qty).toLocaleString()+"";'
'return \'<div class="he-saved-row">\'+'
'\'<button type="button" class="he-saved-pick" data-he-pick data-he-id="\'+esc(it.id)+\'">\'+owner+nm+\' <span class="he-saved-meta">\'+meta+\'</span></button>\'+'
'\'<button type="button" class="he-saved-edit" data-he-edit data-he-id="\'+esc(it.id)+\'" aria-label="수정" title="수정">✎</button>\'+'
'\'<button type="button" class="he-saved-del" data-he-del data-he-id="\'+esc(it.id)+\'" aria-label="삭제" title="삭제">×</button>\'+'
'\'</div>\';'
'}).join("");markActive();}'
'function loadSaved(){'
'fetch("/api/holdeval/list",{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
'savedItems=(d&&d.items)||[];renderSaved(savedItems);'
'}).catch(function(){var w=document.querySelector("[data-he-saved]");if(w)w.innerHTML=\'<div class="muted small">목록 조회 실패</div>\';});'
'}'
'function deleteSaved(id){var body=new URLSearchParams();body.set("id",id);'
'fetch("/holdeval/delete",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},body:body.toString(),credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
'if(d&&d.ok){savedItems=d.items||[];renderSaved(savedItems);if(id===curSel)clearResult();}'
'}).catch(function(){});}'
# ── 추가 팝업(holdeval-add-modal): 검색/선택/저장 ──
'function addStatus(){return document.querySelector("#holdeval-add-modal [data-he-sym-status]");}'
'function resetAddForm(){selCode="";selName="";editId="";editBaseAvg=NaN;editBaseQty=NaN;'
'var m=document.getElementById("holdeval-add-modal");if(!m)return;'
'var f=m.querySelectorAll("[data-he-owner],[data-he-search],[data-he-avg],[data-he-qty],[data-he-addprice],[data-he-addqty]");'
'for(var i=0;i<f.length;i++)f[i].value="";'
'var rb=m.querySelector("[data-he-results]");if(rb){rb.classList.add("hidden");rb.innerHTML="";}'
'var st=m.querySelector("[data-he-sym-status]");if(st)st.textContent="";'
'var mw=m.querySelector("[data-he-merge-wrap]");if(mw)mw.classList.add("hidden");'
'var mh=m.querySelector("[data-he-merge-hint]");if(mh)mh.textContent="";'
'var title=document.getElementById("holdeval-add-modal-title");if(title)title.textContent="항목 추가";'
'var sb=m.querySelector("[data-he-save-btn]");if(sb)sb.textContent="💾 저장";}'
# 수정 — 저장항목을 팝업에 prefill 후 편집(물타기·불타기 후 평단가·주수 갱신).
'function openEdit(id){var it=null;for(var i=0;i<savedItems.length;i++){if(savedItems[i].id===id){it=savedItems[i];break;}}'
'if(!it)return;resetAddForm();'
'editId=id;selCode=it.code||"";selName=it.name||"";editBaseAvg=Number(it.avg);editBaseQty=Number(it.qty);'
'var m=document.getElementById("holdeval-add-modal");if(!m)return;'
'var owner=m.querySelector("[data-he-owner]");if(owner)owner.value=it.owner||"";'
'var search=m.querySelector("[data-he-search]");if(search)search.value=it.name||it.code||"";'
'var avg=m.querySelector("[data-he-avg]");if(avg)avg.value=it.avg;'
'var qty=m.querySelector("[data-he-qty]");if(qty)qty.value=it.qty;'
'var mw=m.querySelector("[data-he-merge-wrap]");if(mw)mw.classList.remove("hidden");' # 수정 모드에서만 추가매수 합산 표시
'var st=m.querySelector("[data-he-sym-status]");if(st){st.textContent=(it.name||it.code)+" ("+it.code+") — 평단가·주수 직접 수정 또는 아래 추가매수 입력";st.style.color="#8b8f9a";}'
'var title=document.getElementById("holdeval-add-modal-title");if(title)title.textContent="항목 수정";'
'var sb=m.querySelector("[data-he-save-btn]");if(sb)sb.textContent="💾 수정 저장";'
'm.classList.remove("hidden");m.setAttribute("aria-hidden","false");if(window.__behiveLockScroll)window.__behiveLockScroll();}'
# 추가매수 합산 — 추가 단가·수량 입력 시 기존(base) 평단·주수와 가중평균으로 합산해 평단가·주수 필드 갱신.
'function recomputeMerge(){var m=document.getElementById("holdeval-add-modal");if(!m)return;'
'var addP=num(m.querySelector("[data-he-addprice]"));var addQ=num(m.querySelector("[data-he-addqty]"));'
'var avgEl=m.querySelector("[data-he-avg]");var qtyEl=m.querySelector("[data-he-qty]");var hint=m.querySelector("[data-he-merge-hint]");'
'if(isNaN(editBaseAvg)||isNaN(editBaseQty)){if(hint)hint.textContent="";return;}'
'if(isNaN(addP)||isNaN(addQ)||addP<=0||addQ<=0){' # 추가 비면 base로 복원
'if(avgEl)avgEl.value=Math.round(editBaseAvg);if(qtyEl)qtyEl.value=Math.round(editBaseQty);'
'if(hint)hint.textContent="";return;'
'}'
'var newQty=editBaseQty+addQ;var newAvg=(editBaseQty*editBaseAvg+addQ*addP)/newQty;'
'if(avgEl)avgEl.value=Math.round(newAvg);if(qtyEl)qtyEl.value=Math.round(newQty);'
'if(hint){var d=newAvg-editBaseAvg;var dir=d<0?"평단 하락(물타기)":(d>0?"평단 상승(불타기)":"평단 동일");'
'hint.textContent="합산 후 평단 "+Math.round(newAvg).toLocaleString()+"원 · "+Math.round(newQty).toLocaleString()+"주 — "+dir;}}'
'function closeAddModal(){var m=document.getElementById("holdeval-add-modal");if(!m)return;'
'm.classList.add("hidden");m.setAttribute("aria-hidden","true");if(window.__behiveUnlockScroll)window.__behiveUnlockScroll();}'
'function pickStock(code,name){selCode=code||"";selName=name||"";var st=addStatus();'
'if(st){st.textContent=(name||code)+" 현재가 불러오는 중…";st.style.color="#7a8493";}'
'fetch("/api/quote_book?code="+encodeURIComponent(code),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
'var p=d&&d.price;if(d&&d.name)selName=d.name;'
'if(!p||isNaN(p)){if(st){st.textContent=(name||code)+" 현재가를 가져오지 못했어요.";st.style.color="#ff4d5e";}return;}'
'if(st){st.textContent=(selName||code)+" ("+code+") 현재가 "+Number(p).toLocaleString()+"";st.style.color="#8b8f9a";}'
'}).catch(function(){if(st){st.textContent=(name||code)+" 현재가 조회 실패";st.style.color="#ff4d5e";}});}'
'function doSearch(){var m=document.getElementById("holdeval-add-modal");if(!m)return;'
'var inp=m.querySelector("[data-he-search]");var rb=m.querySelector("[data-he-results]");'
'var q=inp?inp.value.trim():"";if(!q)return;'
'if(rb){rb.classList.remove("hidden");rb.innerHTML=\'<div class="muted small">검색 중…</div>\';}'
'fetch("/api/stock_search?q="+encodeURIComponent(q),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
'var res=(d&&d.results)||[];'
'if(!res.length){if(rb)rb.innerHTML=\'<div class="muted small">\'+esc((d&&d.error)||"검색 결과 없음")+\'</div>\';return;}'
'if(res.length===1){if(rb){rb.classList.add("hidden");rb.innerHTML="";}if(inp)inp.value=res[0].name||inp.value;pickStock(res[0].code,res[0].name);return;}'
'if(rb)rb.innerHTML=res.map(function(it){return \'<button type="button" class="info-search-hit" data-he-hit data-he-code="\'+esc(it.code)+\'" data-he-name="\'+esc(it.name||"")+\'">\'+esc(it.name||it.code)+\' <span class="muted small">\'+esc(it.code)+\'</span></button>\';}).join("");'
'}).catch(function(){if(rb)rb.innerHTML=\'<div class="muted small">검색 오류</div>\';});}'
'function saveEntry(){var m=document.getElementById("holdeval-add-modal");if(!m)return;var st=addStatus();'
'var avg=num(m.querySelector("[data-he-avg]"));var qty=num(m.querySelector("[data-he-qty]"));'
'var owner=(m.querySelector("[data-he-owner]")||{}).value||"";'
'if(!selCode){if(st){st.textContent="종목을 먼저 검색·선택하세요";st.style.color="#ff4d5e";}return;}'
'if(isNaN(avg)||avg<=0||isNaN(qty)||qty<=0){if(st){st.textContent="평단가·주수를 입력하세요";st.style.color="#ff4d5e";}return;}'
'var body=new URLSearchParams();body.set("owner",owner);body.set("code",selCode);body.set("name",selName||"");body.set("avg",avg);body.set("qty",qty);'
'var url="/holdeval/save";if(editId){url="/holdeval/update";body.set("id",editId);}'
'fetch(url,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},body:body.toString(),credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
'if(!d||!d.ok){if(st){st.textContent=(d&&d.error)||"저장 실패";st.style.color="#ff4d5e";}return;}'
'savedItems=d.items||[];renderSaved(savedItems);'
'var eid=editId;resetAddForm();closeAddModal();'
'if(eid&&eid===curSel)selectSaved(curSel);' # 수정한 항목이 결과영역에 떠있으면 새로 계산
'}).catch(function(){if(st){st.textContent="저장 오류";st.style.color="#ff4d5e";}});}'
# ── 이벤트 위임 ──
'document.addEventListener("input",function(e){if(!e.target||!e.target.closest)return;'
'if(e.target.closest("[data-he-addprice],[data-he-addqty]")){recomputeMerge();return;}'
# 수정 모드에서 추가매수 비었을 때 평단가·주수 수동 수정 → base 갱신 (이후 합산 기준)
'if(editId&&e.target.closest("[data-he-avg],[data-he-qty]")){'
'var m=document.getElementById("holdeval-add-modal");if(!m)return;'
'var ap=num(m.querySelector("[data-he-addprice]"));var aq=num(m.querySelector("[data-he-addqty]"));'
'if(isNaN(ap)||isNaN(aq)||ap<=0||aq<=0){editBaseAvg=num(m.querySelector("[data-he-avg]"));editBaseQty=num(m.querySelector("[data-he-qty]"));}'
'}'
'});'
'document.addEventListener("click",function(e){var t=e.target;'
'if(t.closest&&t.closest("[data-he-search-btn]")){doSearch();return;}'
'if(t.closest&&t.closest("[data-he-save-btn]")){saveEntry();return;}'
'if(t.closest&&t.closest("[data-he-refresh]")){if(curSel)selectSaved(curSel);return;}'
'var ed=t.closest&&t.closest("[data-he-edit]");if(ed){openEdit(ed.getAttribute("data-he-id"));return;}'
'var pk=t.closest&&t.closest("[data-he-pick]");if(pk){selectSaved(pk.getAttribute("data-he-id"));return;}'
'var dl=t.closest&&t.closest("[data-he-del]");if(dl){deleteSaved(dl.getAttribute("data-he-id"));return;}'
'var hit=t.closest&&t.closest("[data-he-hit]");'
'if(hit){var m=document.getElementById("holdeval-add-modal");var rb=m&&m.querySelector("[data-he-results]");var inp=m&&m.querySelector("[data-he-search]");'
'var nm=hit.getAttribute("data-he-name")||"";var cd=hit.getAttribute("data-he-code")||"";'
'if(inp)inp.value=nm||cd;if(rb){rb.classList.add("hidden");rb.innerHTML="";}pickStock(cd,nm);return;}'
'if(t.closest&&t.closest("[data-modal-open=\\"holdeval-add-modal\\"]")){resetAddForm();return;}' # 팝업 열 때 폼 초기화(열기는 generic 핸들러)
'});'
'document.addEventListener("keydown",function(e){'
'if(e.key==="Enter"&&e.target&&e.target.closest&&e.target.closest("#holdeval-add-modal [data-he-search]")){e.preventDefault();doSearch();}'
'});'
# 런처 — ☰ 팝업 안이라 팝업 닫고 목록 로드·결과 초기화.
'document.addEventListener("click",function(e){'
'var b=e.target.closest&&e.target.closest("[data-modal-open=\\"holdeval-modal\\"]");'
'if(!b)return;'
'var pop=document.getElementById("opts-popup");var tg=document.getElementById("opts-toggle");'
'if(pop)pop.setAttribute("hidden","");if(tg)tg.setAttribute("aria-expanded","false");'
'clearResult();loadSaved();'
'});'
'})();</script>'
)
idx_info_modal_html = (
'<div id="idx-info-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="idx-info-modal-title">'
'<div class="modal-overlay" data-modal-close="1"></div>'
@@ -9433,6 +9869,8 @@ def render_html() -> str:
note_modal_html = _render_note_modal()
calc_modal_html = _render_calc_modal()
avg_calc_modal_html = _render_avg_calc_modal()
holdeval_modal_html = _render_holdeval_modal()
holdeval_add_modal_html = _render_holdeval_add_modal()
group_assign_modal_html = _render_group_assign_modal()
group_manage_modal_html = _render_group_manage_modal()
trade_modal_html = _render_trade_modal()
@@ -12065,6 +12503,7 @@ window.openPinModal = openPinModal;
<button type="button" class="account-view-toggle-btn opts-item" data-account-view-state="consolidated" role="menuitem" aria-label="계좌 보기 방식 (합산 / 각계좌별도) 토글" title="합산 / 각계좌별도 토글"><span>계좌 나누기</span></button>
<button type="button" class="opts-item" data-modal-open="calc-modal" role="menuitem" aria-label="등락률 계산기" title="기준가·현재가 등락률 계산기"><span>등락률 계산기</span></button>
<button type="button" class="opts-item" data-modal-open="avgcalc-modal" role="menuitem" aria-label="평단 계산기" title="물타기 평단 계산기 (보유+추가매수)"><span>평단 계산기</span></button>
<button type="button" class="opts-item" data-modal-open="holdeval-modal" role="menuitem" aria-label="보유 평가 계산기" title="임의 종목 검색 + 평단가·주수로 등락률·손익 계산"><span>보유 평가 계산기</span></button>
</div>
</div>
</div>
@@ -12083,6 +12522,8 @@ window.openPinModal = openPinModal;
{note_modal_html}
{calc_modal_html}
{avg_calc_modal_html}
{holdeval_modal_html}
{holdeval_add_modal_html}
{group_assign_modal_html}
{group_manage_modal_html}
{candidate_modal_html}
@@ -12105,6 +12546,7 @@ window.openPinModal = openPinModal;
{nodbltap_script}
{calc_script}
{avg_calc_script}
{holdeval_script}
{details_mutex_script}
{modal_script}
{order_modal_script}
@@ -12749,6 +13191,14 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e:
self._send_json(200, {'results': [], 'error': str(e)})
return
if parsed.path == '/api/holdeval/list':
# 보유 평가 계산기 저장 목록 (라이브 시세 없이 저장 필드만; 현재가는 선택 시 quote_book로 조회).
try:
self._send_json(200, {'items': _load_holdeval_saved()})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'holdeval list failed: {e}'})
return
if parsed.path == '/api/order/advice':
# 거래 모달용: 종목·side 기술적 권장 매수가/매도가 + 근거(이평·ATR). 매도는 avg_price(평단) 선택.
qs = parse_qs(parsed.query)
@@ -13718,6 +14168,63 @@ class Handler(BaseHTTPRequestHandler):
self._send_json(200, {'ok': True, 'lines': _get_chart_hlines(code)})
return
if self.path == '/holdeval/save':
# 보유 평가 계산기 저장 — owner/code/name/avg/qty. code·avg·qty 필수.
owner = (params.get('owner') or [''])[0].strip()
code = (params.get('code') or [''])[0].strip()
name = (params.get('name') or [''])[0].strip()
try:
avg = float((params.get('avg') or ['0'])[0].strip() or '0')
qty = float((params.get('qty') or ['0'])[0].strip() or '0')
except ValueError:
avg = qty = 0
try:
items = _add_holdeval_saved(owner, code, name, avg, qty)
self._send_json(200, {'ok': True, 'items': items})
except ValueError as e:
self._send_json(400, {'ok': False, 'error': str(e)})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': str(e)})
return
if self.path == '/holdeval/update':
# 저장 항목 수정 — id 필수, owner/avg/qty 갱신. code/name은 있으면 교체.
entry_id = (params.get('id') or [''])[0].strip()
owner = (params.get('owner') or [''])[0].strip()
code = (params.get('code') or [''])[0].strip()
name = (params.get('name') or [''])[0].strip()
try:
avg = float((params.get('avg') or ['0'])[0].strip() or '0')
qty = float((params.get('qty') or ['0'])[0].strip() or '0')
except ValueError:
avg = qty = 0
if not entry_id:
self._send_json(400, {'ok': False, 'error': 'id required'})
return
try:
items = _update_holdeval_saved(entry_id, owner, avg, qty, code, name)
self._send_json(200, {'ok': True, 'items': items})
except ValueError as e:
self._send_json(400, {'ok': False, 'error': str(e)})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': str(e)})
return
if self.path == '/holdeval/delete':
entry_id = (params.get('id') or [''])[0].strip()
if not entry_id:
self._send_json(400, {'ok': False, 'error': 'id required'})
return
try:
items = _delete_holdeval_saved(entry_id)
self._send_json(200, {'ok': True, 'items': items})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': str(e)})
return
if self.path == '/notes/set':
# 종목 자유 메모 — stock(name) 또는 code 둘 중 하나는 필수.
# note 가 빈 텍스트면 항목 제거.