auto: 일일 백업 2026-07-15 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,"<");'
|
||||
'html+=\'<option value="\'+it.code+\'" data-name="\'+nm.replace(/"/g,""")+\'">\'+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,"<");'
|
||||
'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>";'
|
||||
'});'
|
||||
'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:
|
||||
|
||||
Reference in New Issue
Block a user