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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-07-15 02:00:02 +09:00
parent ed73ed8b18
commit 66d372b50e
107 changed files with 1592 additions and 1191 deletions
+518 -134
View File
@@ -5388,8 +5388,10 @@ table.market-table .down { color: #4a8cf0; font-weight: 600; }
table.market-table .flat { color: #c9ccd3; }
table.market-table .neutral { color: #8b8f9a; }
table.market-adr th { width: 64px; }
table.market-adr td.adr-val { width: 88px; }
table.market-adr td.adr-val { width: 104px; }
table.market-adr td.adr-val b { font-size: 14px; }
.adr-line { line-height: 1.45; white-space: nowrap; }
.adr-when { font-size: 10px; color: #8b8f9a; margin-right: 3px; }
table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
.adr-tag {
display: inline-block; padding: 1px 6px; border-radius: 999px;
@@ -5437,35 +5439,23 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
.adr-trend-svg .adr-hover { transition: opacity 0.1s; }
.adr-trend-svg .adr-hover-line { stroke: #d6d8dd; stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.7; }
.adr-trend-svg .adr-hover-dot { stroke-width: 1.2; }
.adr-trend-tip {
position: absolute;
background: #11141c;
border: 1px solid #2a2e36;
border-radius: 6px;
padding: 5px 8px;
font-size: 11px;
color: #d6d8dd;
pointer-events: none;
opacity: 0;
transition: opacity 0.1s;
z-index: 5;
white-space: nowrap;
min-width: 110px;
}
.adr-trend-tip .tip-d { font-weight: 600; color: #f0f0f0; font-variant-numeric: tabular-nums; margin-bottom: 2px; }
.adr-trend-tip .tip-line {
display: flex; justify-content: space-between; gap: 10px; align-items: baseline;
/* 선택 지점 정보 차트 아래 정적 정보줄( 플로팅 툴팁 대체). */
.adr-trend-info {
display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 14px;
margin-top: 7px; font-size: 12px; color: #d6d8dd;
font-variant-numeric: tabular-nums;
}
.adr-trend-tip .tip-line[hidden] { display: none; }
.adr-trend-tip .tip-mk { font-size: 10px; color: #8b8f9a; }
.adr-trend-tip .tip-kospi .tip-mk { color: #fbbf24; }
.adr-trend-tip .tip-kosdaq .tip-mk { color: #34d399; }
.adr-trend-tip .tip-v { font-weight: 700; font-variant-numeric: tabular-nums; }
.adr-trend-tip .tip-v.up { color: #ff8a96; }
.adr-trend-tip .tip-v.down { color: #8db4f4; }
.adr-trend-tip .tip-v.flat { color: #d6d8dd; }
.adr-trend-tip .tip-v.live::after { content: " *"; color: #fbbf24; font-weight: 600; }
.adr-trend-info .tip-d { font-weight: 600; color: #f0f0f0; }
.adr-trend-info .tip-line { display: inline-flex; align-items: baseline; gap: 5px; }
.adr-trend-info .tip-line[hidden] { display: none; }
.adr-trend-info .tip-mk { font-size: 11px; color: #8b8f9a; }
.adr-trend-info .tip-kospi .tip-mk { color: #fbbf24; }
.adr-trend-info .tip-kosdaq .tip-mk { color: #34d399; }
.adr-trend-info .tip-v { font-weight: 700; }
.adr-trend-info .tip-v.up { color: #ff8a96; }
.adr-trend-info .tip-v.down { color: #8db4f4; }
.adr-trend-info .tip-v.flat { color: #d6d8dd; }
.adr-trend-info .tip-v.live::after { content: " *"; color: #fbbf24; font-weight: 600; }
.adr-trend-empty {
display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
padding: 8px 0;
@@ -5488,6 +5478,36 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
border-color: rgba(251,191,36,0.4);
}
.adr-range-btn:focus-visible { outline: 1px solid #4a4f5a; outline-offset: 1px; }
/* 오늘 ADR 블록 카드 기본(접힘) 상태에 항상 노출. 시장별 (·판정·종목수). */
.adr-today-block {
display: flex; flex-direction: column; gap: 4px;
margin: 6px 0 2px; font-variant-numeric: tabular-nums;
}
.adr-today-block .adr-today-when { font-size: 11px; color: #8b8f9a; font-weight: 500; }
.adr-today-item {
display: flex; align-items: baseline; flex-wrap: wrap; gap: 3px 8px;
font-size: 13px; color: #d6d8dd;
}
.adr-today-mk { font-size: 11px; color: #8b8f9a; min-width: 38px; }
.adr-today-item b { font-weight: 700; }
.adr-today-item b.up { color: #ff8a96; }
.adr-today-item b.down { color: #8db4f4; }
.adr-today-item b.neutral { color: #d6d8dd; }
.adr-today-breakdown { font-size: 11px; color: #8b8f9a; margin-left: auto; }
/* 추세 그래프 토글 버튼 .adr-range-btn 재사용. */
.adr-graph-toggle {
appearance: none; -webkit-appearance: none;
background: #11141c; color: #c9ccd3;
border: 1px solid #2a2e36; border-radius: 8px;
padding: 6px 12px; margin: 6px 0 2px; font-size: 11px; font-weight: 500; font-family: inherit;
cursor: pointer;
}
.adr-graph-toggle:hover, .adr-graph-toggle:active { background: #1a1d26; color: #f0f0f0; }
.adr-graph-toggle[aria-expanded="true"] {
background: rgba(251,191,36,0.12); color: #fbbf24; border-color: rgba(251,191,36,0.4);
}
.adr-graph-toggle:focus-visible { outline: 1px solid #4a4f5a; outline-offset: 1px; }
.adr-graph-wrap[hidden] { display: none; }
.adr-live-badge {
display: inline-block; margin-left: 6px;
font-size: 9px; font-weight: 600; letter-spacing: 0.04em;
@@ -5552,13 +5572,29 @@ table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
display: flex; flex-direction: column; gap: 4px; min-width: 168px;
}
.opts-popup[hidden] { display: none; }
/* 팝업 메뉴 항목으로 쓰일 때의 계좌 나누기 버튼 가로 채우고 좌측 정렬 + 아이콘. */
/* 팝업 메뉴 항목 공통 스타일 가로 채우고 좌측 정렬 + 아이콘. 자립형(다른 버튼 클래스 없이도 동일). */
.opts-item {
width: 100%; min-width: 0; height: auto; padding: 9px 11px;
justify-content: flex-start; gap: 9px; font-size: 13px; font-weight: 600;
display: flex; align-items: center; justify-content: flex-start;
width: 100%; min-width: 0; height: auto; padding: 9px 11px; gap: 9px;
background: #1c2230; border: 1px solid #2a3142; color: #e6e6e6;
border-radius: 8px; font-size: 13px; font-weight: 600; line-height: 1;
font-family: inherit; cursor: pointer; user-select: none; text-align: left;
transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s;
}
.opts-item .opts-ico { font-size: 16px; line-height: 1; }
.opts-item:hover { background: #232a3b; }
.opts-item:active { transform: scale(0.95); }
.holdings-toggle-wrap { margin-top: 4px; }
/* 등락률 계산기 모달 결과 영역 */
.calc-result { margin-top: 14px; padding: 12px 14px; background: #14171f; border: 1px solid #232838; border-radius: 10px; }
.calc-line { display: flex; align-items: baseline; justify-content: space-between; padding: 4px 0; }
.calc-lbl { font-size: 13px; color: #8b8f9a; }
.calc-val { font-size: 22px; font-weight: 700; color: #8b8f9a; font-variant-numeric: tabular-nums; }
.calc-hint { margin-top: 6px; font-size: 12px; color: #7a8493; }
.calc-hint:empty { display: none; }
.calc-sym-row { display: block; margin-top: 12px; font-size: 13px; 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; }
/* 차트 컨트롤 자산정보 상단의 공통 기간·단위 select 묶음. */
.chart-controls { display: flex; flex-direction: column; gap: 6px; margin: 10px 14px 4px; }
@@ -5823,102 +5859,150 @@ def _format_signed_billion(v: int | None) -> tuple[str, str]:
def _adr_class(adr: float | None) -> tuple[str, str]:
"""ADR 색상·해석 라벨. 통상 ≤75 침체(매수권), ≥125 과열(매도권).
20 이동평균 기준 단일일 raw ratio는 변동성이 커서 임계치 해석이 무의미."""
"""ADR 색상·해석 라벨. ≤80 침체(매수권), ≥120 과열(매도권). 그래프 기준선(80/100/120)과 동일."""
if adr is None:
return 'flat', ''
if adr <= 75:
if adr <= 80:
return 'down', '침체'
if adr >= 125:
if adr >= 120:
return 'up', '과열'
return 'flat', ''
def _build_adr_series_with_live(history_rows: list[dict], live_pt: dict | None) -> list[dict]:
"""history(오래된 순) + 오늘 라이브 1점을 합쳐 [{date,adr,is_live}, ...] 반환.
라이브는 history 마지막 date 다를 때만 append (21:00 누적 후엔 자동 중복)."""
"""history(오래된 순) + 오늘 라이브 1점을 합쳐 [{date,adr,rise,fall,is_live}, ...] 반환.
rise/fall(상승·하락 종목수) 정통 합계식 ADR 산출용. 라이브는 history 마지막 date 다를 때만 append."""
base: list[dict] = [
{'date': r['date'], 'adr': float(r['adr']), 'is_live': False}
{'date': r['date'], 'adr': float(r['adr']),
'rise': r.get('rise'), 'fall': r.get('fall'), 'is_live': False}
for r in history_rows
if r.get('adr') is not None and r.get('date')
]
if live_pt and live_pt.get('adr') is not None and live_pt.get('date'):
last_date = base[-1]['date'] if base else None
if live_pt['date'] != last_date:
base.append({'date': live_pt['date'], 'adr': float(live_pt['adr']), 'is_live': True})
base.append({'date': live_pt['date'], 'adr': float(live_pt['adr']),
'rise': live_pt.get('rise'), 'fall': live_pt.get('fall'), 'is_live': True})
return base
def _compute_adr_ma_latest(symbol: str, today_raw_adr: float | None, today_date: str | None,
def _adr_window_value(win: list[dict]) -> float:
"""정통 ADR = 윈도우 내 Σ상승종목 ÷ Σ하락종목 × 100. 비율의 평균(구방식)은 급등일에 왜곡되므로 합계식 사용.
윈도우 구간에 종목수(rise/fall) 있고 Σ하락>0 때만 합계식, 아니면 일별 비율 평균으로 폴백( 데이터 graceful)."""
rises = [w['rise'] for w in win if w.get('rise') is not None]
falls = [w['fall'] for w in win if w.get('fall') is not None]
if len(rises) == len(win) and len(falls) == len(win) and sum(falls) > 0:
return sum(rises) / sum(falls) * 100
return sum(w['adr'] for w in win) / len(win)
def _compute_adr_ma_latest(symbol: str, today_row: dict | None = None,
window: int = ADR_MA_WINDOW) -> tuple[float | None, int, bool]:
"""심볼별 최근 N일 이평. (avg or None, available_count, used_live).
available_count < window 이면 avg=None (= "누적 중 count/window")."""
"""심볼별 최근 N일 정통 ADR. (value or None, available_count, used_live).
today_row = 오늘 라이브 (adr·rise·fall·bizdate). available_count < window 이면 '누적 중 count/window'."""
history = _load_market_history(-1).get(symbol, [])
live_pt = ({'date': today_date, 'adr': today_raw_adr}
if today_raw_adr is not None and today_date else None)
live_pt = None
if today_row and today_row.get('adr') is not None:
bd = today_row.get('bizdate')
iso = f'{bd[:4]}-{bd[4:6]}-{bd[6:]}' if (bd and len(bd) == 8) else today_row.get('date')
if iso:
live_pt = {'date': iso, 'adr': today_row['adr'],
'rise': today_row.get('rise'), 'fall': today_row.get('fall')}
base = _build_adr_series_with_live(history, live_pt)
if not base:
return (None, 0, False)
used = base[-window:]
used_live = any(r.get('is_live') for r in used)
# 확장 윈도우: window 미만이어도 가용한 만큼의 평균을 돌려준다(차트와 동일 정책). count로 누적 여부 표기.
avg = sum(r['adr'] for r in used) / len(used)
return (avg, len(used), used_live)
# 확장 윈도우: window 미만이어도 가용한 만큼으로 계산(차트와 동일 정책). count로 누적 여부 표기.
return (_adr_window_value(used), len(used), used_live)
def _compute_adr_ma_series(rows: list[dict], window: int = ADR_MA_WINDOW) -> list[tuple[str, float, bool]]:
"""rows(오래된 순, {date,adr,is_live}) → 각 시점 trailing window 평균 시리즈.
[(date, ma, is_live), ...].
확장 윈도우: window 미만 누적 구간은 '가용한 만큼'(최대 window일) 평균으로 점을 만든다
1일차부터 그려지고 누적이 window에 도달하면 정상 trailing-window 평균과 동일(steady-state 동작 불변)."""
"""rows(오래된 순, {date,adr,rise,fall,is_live}) → 각 시점 trailing window 정통 ADR 시리즈.
[(date, adr, is_live), ...]. 값은 Σ상승÷Σ하락×100 (급등일 왜곡 없는 표준식).
누적 < window(20) 초반 구간은 정식 지표가 아니라 아예 점을 만들지 않는다 그래프 미표시."""
series: list[tuple[str, float, bool]] = []
for i in range(len(rows)):
win = rows[max(0, i + 1 - window) : i + 1]
avg = sum(r['adr'] for r in win) / len(win)
if i + 1 < window: # 20일 미만 누적 — 정식 20일 ADR 불가, 그래프에서 제외
continue
win = rows[i + 1 - window : i + 1]
val = _adr_window_value(win)
is_live = any(r.get('is_live') for r in win)
series.append((rows[i]['date'], avg, is_live))
series.append((rows[i]['date'], val, is_live))
return series
def _render_adr_summary_block(rows: list[dict] | None) -> str:
"""ADR 20일 이평 표 + 임계치 hint. ADR 추세 카드 상단 mini section.
rows가 비면 안내 텍스트로 graceful degrade 차트 카드 자체는 계속 그려진다."""
def _render_adr_basic_block(rows: list[dict] | None) -> str:
"""ADR 기본정보 — 20일 정통 ADR + 침체/과열 배지. ADR 추세 카드 기본(접힘) 상태에 항상 노출.
배지·색은 20 기준(80 침체·120 과열). 20 누적 미만이면 배지 없이 (n/20) 표기."""
if not rows:
return (
'<div class="market-section">'
'<div class="adr-today-block">'
+ '<div class="market-section-hint">시장 지표 미수신</div>'
+ '</div>'
)
body_rows: list[str] = []
items: list[str] = []
for r in rows:
raw_adr = r.get('adr')
bd = r.get('bizdate')
today_iso = f'{bd[:4]}-{bd[4:6]}-{bd[6:]}' if (bd and len(bd) == 8) else None
ma, count, _used_live = _compute_adr_ma_latest(r['symbol'], raw_adr, today_iso)
full = count >= ADR_MA_WINDOW
adr_cls, adr_tag = _adr_class(ma)
ma, count, _ = _compute_adr_ma_latest(r['symbol'], r)
note = ''
if ma is None:
adr_str = ''
sub_html = '<div class="adr-sub muted small">미수신</div>'
elif full:
adr_str = f'{ma:.1f}'
sub_html = ''
ma_str, ma_cls, tag_html = '', 'neutral', ''
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 ''
else:
# 누적 < 20일 — 숫자는 보여주되 과열/침체 판정은 보류(짧은 윈도우는 임계치 해석 부정확).
adr_str = f'{ma:.1f}'
sub_html = f'<div class="adr-sub muted small">누적 {count}/{ADR_MA_WINDOW}일 평균</div>'
adr_cls, adr_tag = '', ''
tag_html = f' <span class="adr-tag {adr_cls}">{adr_tag}</span>' if adr_tag else ''
body_rows.append(
f'<tr>'
f'<th>{html.escape(r["label"])}</th>'
f'<td class="adr-val"><b class="{adr_cls}">{adr_str}</b>{tag_html}{sub_html}</td>'
f'<td class="adr-breakdown"><span class="up">{r["rise"]:,}↑</span> / <span class="down">{r["fall"]:,}↓</span> / <span class="neutral">{r["steady"]:,}→</span></td>'
f'</tr>'
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="market-section">'
+ f'<table class="market-table market-adr"><tbody>{"".join(body_rows)}</tbody></table>'
'<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:
raw_adr = r.get('adr')
if raw_adr is None:
raw_str, raw_cls, tag_html = '', 'neutral', ''
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 ''
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>'
)
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>'
)
return (
'<div class="adr-today-block">'
+ '<span class="adr-today-when">오늘</span>'
+ ''.join(items)
+ '</div>'
)
@@ -6177,7 +6261,8 @@ def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
continue
iso = f'{bd[:4]}-{bd[4:6]}-{bd[6:]}' if (bd and len(bd) == 8) else None
if iso:
live_by_market[sym] = {'adr': adr, 'date': iso}
# rise/fall 도 실어야 오늘 포함 윈도우가 정통 합계식으로 계산됨(없으면 그 구간만 비율평균 폴백).
live_by_market[sym] = {'adr': adr, 'date': iso, 'rise': r.get('rise'), 'fall': r.get('fall')}
# 시장별 trailing 20일 이평 시리즈 — (date, ma, is_live).
# raw 시리즈(history+오늘 라이브)에서 시점별 trailing 평균 산출 후, days 파라미터로 마지막 N개 trim.
@@ -6193,7 +6278,14 @@ def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
ma_series = ma_series[-days:]
market_pts[sym] = ma_series
summary_block = _render_adr_summary_block(live_rows)
# 기본(접힘)엔 20일 ADR(+배지). 오늘 raw·종목수는 추세 그래프(펼침) 안으로 이관.
basic_block = _render_adr_basic_block(live_rows)
today_detail = _render_adr_today_detail(live_rows)
# 그래프 펼침 토글 버튼 — 클릭 시 .adr-graph-wrap hidden 토글(순수 클라이언트, localStorage 보존).
graph_toggle = (
'<button type="button" class="adr-graph-toggle" data-adr-graph-toggle aria-expanded="false">'
'<span class="adr-graph-toggle-ic">📈</span> 추세 그래프</button>'
)
# 두 시장 합쳐 점이 하나도 없으면 누적 부족 안내.
all_dates = sorted({d for pts in market_pts.values() for d, _, _ in pts if d})
@@ -6205,17 +6297,30 @@ def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
'<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>'
+ summary_block
+ basic_block
+ 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>'
+ '</div>'
)
date_to_i = {d: i for i, d in enumerate(all_dates)}
n_dates = len(all_dates)
# y축 스케일 — 0~200 고정이면 데이터(통상 40~90)가 아래에 눌려 밋밋함. 데이터 범위에 맞추되
# 기준선 80/100/120 이 항상 보이도록 그 밴드를 범위에 포함시킨다.
_all_vals = [v for sym in ('KOSPI', 'KOSDAQ') for _, v, _ in market_pts[sym]]
if _all_vals:
_dmin, _dmax = min(_all_vals), max(_all_vals)
else:
_dmin, _dmax = 80.0, 120.0
_lo, _hi = min(_dmin, 80.0), max(_dmax, 120.0)
_pad = max(6.0, (_hi - _lo) * 0.1)
Y_MIN, Y_MAX = max(0.0, _lo - _pad), _hi + _pad
# 차트 면적 — 두 라인 + 가이드 + 마커 라벨 여유. height는 sparkline 단일 70 대비 95 로 확장.
Y_MIN, Y_MAX = 0.0, 200.0
W, H = 360.0, 95.0
PAD_L, PAD_R, PAD_T, PAD_B = 4.0, 38.0, 10.0, 14.0
PLOT_W = W - PAD_L - PAD_R
@@ -6230,16 +6335,21 @@ def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
v = max(Y_MIN, min(Y_MAX, v))
return PAD_T + PLOT_H * (1 - (v - Y_MIN) / (Y_MAX - Y_MIN))
# 가이드선 — 75/100/125. 두 라인 위에 깔리지만 흐려서 노이즈 적음.
y75, y100, y125 = y_for(75), y_for(100), y_for(125)
guides = (
f'<line x1="{PAD_L:.1f}" x2="{W-PAD_R:.1f}" y1="{y100:.1f}" y2="{y100:.1f}" stroke="#8b8f9a" stroke-width="0.6" stroke-dasharray="2 4" opacity="0.2"/>'
f'<line x1="{PAD_L:.1f}" x2="{W-PAD_R:.1f}" y1="{y75:.1f}" y2="{y75:.1f}" stroke="#4a8cf0" stroke-width="0.6" stroke-dasharray="3 3" opacity="0.22"/>'
f'<line x1="{PAD_L:.1f}" x2="{W-PAD_R:.1f}" y1="{y125:.1f}" y2="{y125:.1f}" stroke="#ff4d5e" stroke-width="0.6" stroke-dasharray="3 3" opacity="0.22"/>'
f'<text x="{W-PAD_R+3:.1f}" y="{y100:.1f}" dominant-baseline="middle" fill="#8b8f9a" font-size="9" opacity="0.35">100</text>'
f'<text x="{W-PAD_R+3:.1f}" y="{y75:.1f}" dominant-baseline="middle" fill="#4a8cf0" font-size="9" opacity="0.4">75</text>'
f'<text x="{W-PAD_R+3:.1f}" y="{y125:.1f}" dominant-baseline="middle" fill="#ff4d5e" font-size="9" opacity="0.4">125</text>'
)
# 가이드선 — 기준 80/100/120. y축이 이 밴드를 포함하므로 셋 다 표시.
_guide_specs = [
(100, '#8b8f9a', '2 4', 0.25, 0.4),
(80, '#4a8cf0', '3 3', 0.25, 0.45),
(120, '#ff4d5e', '3 3', 0.25, 0.45),
]
guides = ''
for gv, gcolor, gdash, gline_op, gtext_op in _guide_specs:
if not (Y_MIN <= gv <= Y_MAX):
continue
gy = y_for(gv)
guides += (
f'<line x1="{PAD_L:.1f}" x2="{W-PAD_R:.1f}" y1="{gy:.1f}" y2="{gy:.1f}" stroke="{gcolor}" stroke-width="0.6" stroke-dasharray="{gdash}" opacity="{gline_op}"/>'
f'<text x="{W-PAD_R+3:.1f}" y="{gy:.1f}" dominant-baseline="middle" fill="{gcolor}" font-size="9" opacity="{gtext_op}">{gv}</text>'
)
# 시장별 이평선 path + 마지막 마커. 이평은 매끄러운 단일 시리즈라 saved/live 분리 점선 불필요.
# 마지막 점이 오늘 라이브 raw를 포함한 평균이면 (is_live) open circle + 별표 마커.
@@ -6289,12 +6399,13 @@ def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
pts_meta.append(entry)
pts_json = html.escape(json.dumps(pts_meta, separators=(',', ':')), quote=True)
# 툴팁 — 날짜 + 두 시장 값 (각각 색상). 시장 값 비어있으면 JS가 해당 줄 hide.
tip_html = (
'<div class="adr-trend-tip" aria-hidden="true">'
'<div class="tip-d">—</div>'
'<div class="tip-line tip-kospi"><span class="tip-mk">코스피</span><span class="tip-v">—</span></div>'
'<div class="tip-line tip-kosdaq"><span class="tip-mk">코스</span><span class="tip-v">—</span></div>'
# 선택 지점 정보 — 차트 아래 정적 정보줄(구 플로팅 툴팁 대체). 기본은 최신 지점, 차트를 짚으면 갱신.
# 시장 값 비어있으면 JS가 해당 줄 hide.
info_html = (
'<div class="adr-trend-info" aria-live="polite">'
'<span class="tip-d">—</span>'
'<span class="tip-line tip-kospi"><span class="tip-mk">코스</span><span class="tip-v">—</span></span>'
'<span class="tip-line tip-kosdaq"><span class="tip-mk">코스닥</span><span class="tip-v">—</span></span>'
'</div>'
)
@@ -6332,7 +6443,7 @@ def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
f'{x_axis_label}'
f'{hover_g}'
f'</svg>'
f'{tip_html}'
f'{info_html}'
f'</div>'
)
@@ -6342,12 +6453,16 @@ def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
'<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>'
+ summary_block
+ f'<div class="market-section-hint">회색점선=100(균형) · 파란점선=75(침체) · 빨간점선=125(과열) · *=오늘(라이브 포함)</div>'
+ basic_block
+ 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
+ chart_row
+ '</div>'
+ '</div>'
)
@@ -6632,6 +6747,81 @@ def _render_note_modal() -> str:
)
def _render_calc_modal() -> str:
"""등락률 계산기 모달. ☰ 옵션 팝업의 '계산기' 항목이 연다.
기준가·현재가를 입력하면 등락률(%) 차이 금액을 클라이언트에서 즉시 계산.
서버 왕복·키움 호출 없음."""
return (
'<div id="calc-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="calc-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="calc-modal-title">등락률 계산기</div>'
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
'</div>'
'<div class="add-form">'
'<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>'
'</div>'
'<label class="calc-sym-row">종목에서 현재가 가져오기 '
'<select class="order-symbol-select" data-calc-symbol aria-label="관심·보유·감시 종목에서 현재가 불러오기">'
'<option value="">종목 선택 (관심·보유·감시)…</option>'
'</select></label>'
'<div class="calc-sym-status" data-calc-sym-status></div>'
'<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-hint" data-calc-hint>기준가·현재가를 입력하세요.</div>'
'</div>'
'</div>'
'<div class="modal-actions">'
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
'</div>'
'</div>'
'</div>'
)
def _render_avg_calc_modal() -> str:
"""평단 계산기(물타기) 모달. ☰ 옵션 팝업의 '평단 계산기' 항목이 연다.
보유수량·현재평단가·추가매수 주수·추가매수가 평단·총수량·총매입금 즉시 계산(클라이언트).
'내 계좌 종목' 고르면 보유수량·현재평단가 자동 입력(/api/holdings, code별 계좌 가중평균)."""
return (
'<div id="avgcalc-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="avgcalc-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="avgcalc-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">내 계좌 종목에서 불러오기 '
'<select class="order-symbol-select" data-avg-symbol aria-label="보유 종목에서 평단·수량 불러오기">'
'<option value="">보유 종목 선택…</option>'
'</select></label>'
'<div class="calc-sym-status" data-avg-sym-status></div>'
'<div class="add-form-grid">'
'<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>'
'</div>'
'<div class="calc-result" data-avg-result>'
'<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>'
'</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 체크. 그룹 인라인 추가 가능."""
@@ -7772,6 +7962,27 @@ def render_html() -> str:
# 시장정보 sub-tab은 자산정보 탭의 일부 → owner=null full fetch (fresh=true).
'if(window.__behive_load)window.__behive_load(null,true);'
'});'
# ADR 추세 그래프 펼침/접힘 토글 — 순수 클라이언트(그래프 SVG는 이미 DOM). localStorage 보존.
'function applyAdrGraph(open){'
'document.querySelectorAll(".adr-graph-wrap").forEach(function(w){'
'if(open)w.removeAttribute("hidden");else w.setAttribute("hidden","");'
'});'
'document.querySelectorAll(".adr-graph-toggle").forEach(function(b){'
'b.setAttribute("aria-expanded",open?"true":"false");'
'});'
'}'
'function restoreAdrGraph(){'
'var open=false;try{open=localStorage.getItem("behive.adrGraphOpen")==="1";}catch(_){}'
'if(open)applyAdrGraph(true);'
'}'
'document.addEventListener("click",function(e){'
'var bt=e.target.closest&&e.target.closest(".adr-graph-toggle");'
'if(!bt)return;'
'e.preventDefault();'
'var open=bt.getAttribute("aria-expanded")!=="true";'
'try{localStorage.setItem("behive.adrGraphOpen",open?"1":"0");}catch(_){}'
'applyAdrGraph(open);'
'});'
# select 드롭다운(기간·단위) 변경 핸들러 — 자산정보 탭 상단의 공통 컨트롤.
# chip 흐름과 동일한 localStorage 키 사용 → 백엔드 변경 없음.
'document.addEventListener("change",function(e){'
@@ -7812,7 +8023,7 @@ def render_html() -> str:
'var ow=window.__behive_active_owner?window.__behive_active_owner():null;'
'if(window.__behive_load)window.__behive_load(ow,true);'
'});'
'function scan(){document.querySelectorAll(".net-chart[data-pts]").forEach(init);restoreSubs();restoreAccountViews();restoreIgrp();}'
'function scan(){document.querySelectorAll(".net-chart[data-pts]").forEach(init);restoreSubs();restoreAccountViews();restoreIgrp();restoreAdrGraph();}'
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",scan);'
'else scan();'
# swap 후 새 차트가 들어오면 재바인딩 + sub-tab 복원
@@ -7826,13 +8037,13 @@ def render_html() -> str:
# 자동 갱신(panels innerHTML swap) 후 init 시 동일 날짜로 indicator 복원.
adr_hover_script = (
'<script>(function(){'
'function cls(v){return v>=125?"up":(v<=75?"down":"flat");}'
'function cls(v){return v>=120?"up":(v<=80?"down":"flat");}'
# selectedDate — 마지막 선택 날짜 ISO('YYYY-MM-DD'). 단일 차트라 시장 키 불필요.
'var selectedDate=null;'
'function init(row){'
'if(row.__adr_bound)return;row.__adr_bound=true;'
'var svg=row.querySelector("svg.adr-trend-svg");'
'var tip=row.querySelector(".adr-trend-tip");'
'var tip=row.querySelector(".adr-trend-info");'
'var hg=svg&&svg.querySelector("g.adr-hover");'
'if(!svg||!tip||!hg)return;'
'var line=hg.querySelector("line");'
@@ -7876,28 +8087,7 @@ def render_html() -> str:
'dEl.textContent=dlbl+(dow?" ("+dow+")":"");'
'setTipLine(lineK,vK,pk);'
'setTipLine(lineD,vD,pd);'
'tip.style.opacity="1";'
'if(save!==false&&p.d)selectedDate=p.d;'
# 위치 — svg 내 x 비율로 변환 후 row(absolute parent) 기준 left 보정.
'requestAnimationFrame(function(){'
'var r=svg.getBoundingClientRect();if(!r.width)return;'
'var rr=row.getBoundingClientRect();'
'var pxX=p.x/VW*r.width+(r.left-rr.left);'
'var tw=tip.offsetWidth;'
'var lx=pxX-tw/2;'
'var maxL=rr.width-tw-4;'
'if(lx<4)lx=4;if(lx>maxL)lx=maxL;'
'tip.style.left=lx+"px";'
# top: 두 점 평균 y 가 차트 상단이면 svg 아래, 하단이면 svg 위.
'var svgTop=r.top-rr.top;'
'var th=tip.offsetHeight;'
'var ys=[];if(pk)ys.push(pk.y);if(pd)ys.push(pd.y);'
'var avgY=ys.length?(ys.reduce(function(a,b){return a+b;},0)/ys.length):VH/2;'
'var pyRatio=avgY/VH;'
'var ty=(pyRatio<0.5)?(svgTop+r.height-th-2):(svgTop-th-2);'
'if(ty<-2)ty=svgTop+r.height-th-2;'
'tip.style.top=ty+"px";'
'});'
'}'
'function moveByPx(cx){'
'var r=svg.getBoundingClientRect();if(!r.width)return;'
@@ -7908,12 +8098,14 @@ def render_html() -> str:
'}'
'svg.addEventListener("pointermove",function(e){moveByPx(e.clientX);});'
'svg.addEventListener("pointerdown",function(e){moveByPx(e.clientX);});'
# 영속 복원 — 마지막 선택 날짜가 있고 새 데이터에도 있으면 indicator 재배치.
# 영속 복원 — 마지막 선택 날짜가 있으면 재배치, 없으면 최신 지점을 기본 표시.
'var restored=false;'
'if(selectedDate){'
'for(var k=0;k<pts.length;k++){'
'if(pts[k].d===selectedDate){applyPoint(pts[k],false);break;}'
'if(pts[k].d===selectedDate){applyPoint(pts[k],false);restored=true;break;}'
'}'
'}'
'if(!restored)applyPoint(pts[pts.length-1],false);'
'}'
'function scan(){document.querySelectorAll(".adr-trend-row[data-adr-pts]").forEach(init);}'
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",scan);'
@@ -8400,6 +8592,161 @@ def render_html() -> str:
'})();</script>'
)
# 등락률 계산기 모달 로직 — 기준가·현재가 입력 즉시 % / 차이 금액 계산 (순수 클라이언트).
calc_script = (
'<script>(function(){'
'var UP="#ff4d5e",DOWN="#4a8cf0",NEU="#8b8f9a";'
'function num(el){if(!el)return NaN;var v=parseFloat(el.value);return isFinite(v)?v:NaN;}'
'function recalc(){'
'var base=num(document.querySelector("[data-calc-base]"));'
'var cur=num(document.querySelector("[data-calc-cur]"));'
'var pctEl=document.querySelector("[data-calc-pct]");'
'var diffEl=document.querySelector("[data-calc-diff]");'
'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(hintEl)hintEl.textContent=(!isNaN(base)&&base===0)?"기준가는 0이 될 수 없어요.":"기준가·현재가를 입력하세요.";'
'return;'
'}'
'var pct=(cur-base)/base*100;'
'var diff=cur-base;'
'var col=diff>0?UP:(diff<0?DOWN:NEU);'
'var psign=pct>0?"+":(pct<0?"":"");'
'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(hintEl)hintEl.textContent="";'
'}'
'document.addEventListener("input",function(e){'
'if(e.target&&e.target.closest&&e.target.closest("[data-calc-base],[data-calc-cur]"))recalc();'
'});'
# ── 관심·보유·감시 종목에서 현재가 자동 입력 ──
'var symLoaded=false;'
'function loadSymbols(){'
'if(symLoaded)return;'
'var sel=document.querySelector("[data-calc-symbol]");if(!sel)return;'
'symLoaded=true;'
'fetch("/api/symbols/all",{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
'var items=(d&&d.items)||[];'
'var groups={"보유":[],"관심":[],"감시":[]};'
'items.forEach(function(it){(groups[it.source]||groups["관심"]).push(it);});'
'var html=\'<option value="">종목 선택 (관심·보유·감시)…</option>\';'
'["보유","관심","감시"].forEach(function(g){'
'var rows=groups[g];if(!rows||!rows.length)return;'
'html+=\'<optgroup label="\'+g+\'">\';'
'rows.forEach(function(it){'
'var nm=(it.name||it.code).replace(/</g,"&lt;");'
'html+=\'<option value="\'+it.code+\'" data-name="\'+nm.replace(/"/g,"&quot;")+\'">\'+nm+\'</option>\';'
'});'
'html+="</optgroup>";'
'});'
'sel.innerHTML=html;'
'}).catch(function(){symLoaded=false;});'
'}'
'document.addEventListener("change",function(e){'
'var sel=e.target.closest&&e.target.closest("[data-calc-symbol]");if(!sel)return;'
'var opt=sel.selectedOptions&&sel.selectedOptions[0];'
'var code=sel.value;'
'var nm=(opt&&opt.getAttribute("data-name"))||"";'
'var st=document.querySelector("[data-calc-sym-status]");'
'var curEl=document.querySelector("[data-calc-cur]");'
'if(!code){if(st)st.textContent="";return;}'
'if(st){st.textContent=nm+" 현재가 불러오는 중…";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(!p||isNaN(p)){if(st){st.textContent=nm+" 현재가를 가져오지 못했어요.";st.style.color="#ff4d5e";}return;}'
'if(curEl){curEl.value=p;}'
'if(st){st.textContent=(nm||(d.name||""))+" 현재가 "+Number(p).toLocaleString()+"원 적용";st.style.color="#8b8f9a";}'
'recalc();'
'}).catch(function(){if(st){st.textContent=nm+" 현재가 조회 실패";st.style.color="#ff4d5e";}});'
'});'
# 계산기 런처는 ☰ 팝업 안이라 팝업이 안 닫힘 → 모달 열 때 팝업 닫고 종목목록 로드.
'document.addEventListener("click",function(e){'
'var b=e.target.closest&&e.target.closest("[data-modal-open=\\"calc-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");'
'loadSymbols();'
'});'
'})();</script>'
)
# 평단 계산기(물타기) 로직 — 보유수량·현재평단가·추가매수 주수·추가매수가 즉시 계산 (순수 클라이언트).
avg_calc_script = (
'<script>(function(){'
'function num(el){if(!el)return NaN;var v=parseFloat(el.value);return isFinite(v)?v:NaN;}'
'function won(n){return Math.round(n).toLocaleString()+"";}'
'function recalc(){'
'var hq=num(document.querySelector("[data-avg-hqty]"));'
'var ha=num(document.querySelector("[data-avg-havg]"));'
'var aq=num(document.querySelector("[data-avg-aqty]"));'
'var ap=num(document.querySelector("[data-avg-aprice]"));'
'var naEl=document.querySelector("[data-avg-newavg]");'
'var tqEl=document.querySelector("[data-avg-totqty]");'
'var tcEl=document.querySelector("[data-avg-totcost]");'
'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="보유수량·현재평단가·추가매수 주수·추가매수가를 입력하세요.";'
'return;'
'}'
'var totQty=hq+aq;var totCost=hq*ha+aq*ap;var newAvg=totCost/totQty;'
'naEl.textContent=won(newAvg);'
'tqEl.textContent=Math.round(totQty).toLocaleString()+"";'
'tcEl.textContent=won(totCost);'
'var col=newAvg<ha?"#4a8cf0":(newAvg>ha?"#ff4d5e":"#8b8f9a");naEl.style.color=col;'
'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?"하락":"상승"));}'
'}'
'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();'
'});'
# ── 내 계좌 보유종목에서 보유수량·현재평단가 자동 입력 (code별 전 계좌 가중평균) ──
'var hLoaded=false;'
'function loadHoldings(){'
'if(hLoaded)return;'
'var sel=document.querySelector("[data-avg-symbol]");if(!sel)return;'
'hLoaded=true;'
'fetch("/api/holdings",{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
'var items=(d&&d.items)||[];'
'var html=\'<option value="">보유 종목 선택…</option>\';'
'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>";'
'});'
'sel.innerHTML=html;'
'}).catch(function(){hLoaded=false;});'
'}'
'document.addEventListener("change",function(e){'
'var sel=e.target.closest&&e.target.closest("[data-avg-symbol]");if(!sel)return;'
'var opt=sel.selectedOptions&&sel.selectedOptions[0];'
'var st=document.querySelector("[data-avg-sym-status]");'
'if(!sel.value||!opt){if(st)st.textContent="";return;}'
'var q=opt.getAttribute("data-qty"),a=opt.getAttribute("data-avg");'
'var nm=opt.getAttribute("data-name")||"";'
'var hq=document.querySelector("[data-avg-hqty]");var ha=document.querySelector("[data-avg-havg]");'
'if(hq)hq.value=q;if(ha)ha.value=a;'
'if(st){st.textContent=nm+" 보유 "+Number(q).toLocaleString()+"주 · 평단 "+Number(a).toLocaleString()+"원 적용";st.style.color="#8b8f9a";}'
'recalc();'
'});'
# 런처 — ☰ 팝업 안이라 팝업 닫고 보유목록 로드.
'document.addEventListener("click",function(e){'
'var b=e.target.closest&&e.target.closest("[data-modal-open=\\"avgcalc-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");'
'loadHoldings();'
'});'
'})();</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>'
@@ -8427,6 +8774,8 @@ def render_html() -> str:
interest_edit_modal_html = _render_interests_edit_modal()
tag_modal_html = _render_tag_modal()
note_modal_html = _render_note_modal()
calc_modal_html = _render_calc_modal()
avg_calc_modal_html = _render_avg_calc_modal()
group_assign_modal_html = _render_group_assign_modal()
group_manage_modal_html = _render_group_manage_modal()
trade_modal_html = _render_trade_modal()
@@ -10629,7 +10978,9 @@ window.openPinModal = openPinModal;
<div class="opts-wrap">
<button type="button" id="opts-toggle" class="opts-btn" aria-haspopup="true" aria-expanded="false" aria-label="옵션" title="옵션"></button>
<div class="opts-popup" id="opts-popup" hidden role="menu" aria-label="옵션">
<button type="button" class="account-view-toggle-btn opts-item" data-account-view-state="consolidated" role="menuitem" aria-label="계좌 보기 방식 (합산 / 각계좌별도) 토글" title="합산 / 각계좌별도 토글"><span class="opts-ico"></span><span>계좌 나누기</span></button>
<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>
</div>
</div>
</div>
@@ -10646,6 +10997,8 @@ window.openPinModal = openPinModal;
{interest_edit_modal_html}
{tag_modal_html}
{note_modal_html}
{calc_modal_html}
{avg_calc_modal_html}
{group_assign_modal_html}
{group_manage_modal_html}
{candidate_modal_html}
@@ -10664,6 +11017,8 @@ window.openPinModal = openPinModal;
{chart_svg_script}
{auto_reload_script}
{nodbltap_script}
{calc_script}
{avg_calc_script}
{details_mutex_script}
{modal_script}
{order_modal_script}
@@ -10994,6 +11349,35 @@ class Handler(BaseHTTPRequestHandler):
traceback.print_exc()
self._send_json(500, {'error': f'symbols failed: {e}'})
return
if parsed.path == '/api/holdings':
# 평단 계산기용 — 소유자(본인/가희)별로 보유종목 합산(일반+ISA 합침, 가중평균 평단).
# 같은 종목을 본인·가희 둘 다 보유하면 각각 별도 항목.
try:
import kiwoom_client as kc
agg: dict = {}
pos_by_acc = kc.get_positions_all()
for label, positions in (pos_by_acc or {}).items():
owner = '가희' if label.startswith('가희_') else '본인'
for p in positions:
code = p.get('code')
qty = int(p.get('qty') or 0)
avg = float(p.get('avg_price') or 0)
if not code or qty <= 0:
continue
a = agg.setdefault((owner, code), {'code': code, 'name': p.get('name', ''),
'owner': owner, 'qty': 0, 'cost': 0.0})
a['qty'] += qty
a['cost'] += qty * avg
items = []
for a in agg.values():
items.append({'code': a['code'], 'name': a['name'], 'owner': a['owner'],
'qty': a['qty'], 'avg': int(round(a['cost'] / a['qty'])) if a['qty'] else 0})
items.sort(key=lambda x: x['qty'] * x['avg'], reverse=True)
self._send_json(200, {'items': items, 'count': len(items)})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'holdings failed: {e}'})
return
if parsed.path == '/api/orders/open':
# 4계좌 통합 미체결 주문 — open-orders-modal이 표시
try: