auto: 일일 백업 2026-07-29 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,"<");'
|
||||
'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,""")+\'">\'+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,""")+\'">\'+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{"&":"&","<":"<",">":">","\\"":"""}[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 가 빈 텍스트면 항목 제거.
|
||||
|
||||
Reference in New Issue
Block a user