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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-07-30 02:00:02 +09:00
parent c5dd2f98a8
commit a5f6b0ecd8
181 changed files with 5372 additions and 5251 deletions
+364 -44
View File
@@ -71,7 +71,7 @@ NET_WORTH_ALLOWED_UNITS_BY_DAYS: dict[int, tuple[str, ...]] = {
}
# 차트 표시 모드 — 'pnl'=시작점 baseline=0 누적손익(기본), 'net'=순자산 시계열.
# 'pnl'=시작점 baseline=0 누적손익(전부 누적), 'period'=각 점이 그 기간(일/주/월/연) 자체 손익, 'net'=순자산 시계열.
NET_WORTH_MODE_PRESETS = [('자산', 'net'), ('손익누적', 'pnl'), ('기간손익', 'period')]
NET_WORTH_MODE_PRESETS = [('자산', 'net'), ('손익누적', 'pnl'), ('기간손익', 'period')]
NET_WORTH_CHART_MODE = 'pnl'
KST = ZoneInfo('Asia/Seoul')
BIND_HOST = '' # 모든 인터페이스 (Tailscale + LAN). 외부망 직접 차단은 공유기 NAT가 담당.
@@ -1234,6 +1234,16 @@ def _build_owner_data(positions_by_acc: dict, balances: dict, journal_by_label:
day_pl_total = None
day_pl_reason = 'closed'
# 투자원금(누적 입금−출금) — 캐시 파일 읽기만 (키움 콜 0).
# principal None = 미집계(부트스트랩 전) → KPI 2행 미표시.
try:
import investment_principal as ip
pr = ip.get_principal(lbls)
except Exception as e:
sys.stderr.write(f'principal load failed: {e}\n')
pr = {'principal': None, 'cash_in': None, 'cash_out': None,
'qty_flow': False, 'by_label': {}}
owner_data[owner] = {
'labels': lbls,
'consolidated': consolidated,
@@ -1254,6 +1264,11 @@ def _build_owner_data(positions_by_acc: dict, balances: dict, journal_by_label:
'cash_out': cash_out,
'net_cash_flow': net_cash_flow,
'cash_flows': owner_cash_flows,
'principal': pr['principal'],
'principal_cash_in': pr['cash_in'],
'principal_cash_out': pr['cash_out'],
'principal_qty_flow': pr['qty_flow'],
'principal_by_label': pr['by_label'],
}
# 당일정산(phantom) 종목에 현재가 주입 — 매도 시점과 시장 종가 비교용.
@@ -1457,6 +1472,15 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
sys.stderr.write(f'cash flow fetch failed: {e}\n')
cash_flow_by_label = {}
# 투자원금 — 방금 받은 당일 입출금이 캐시에 미반영일 때만 올해 창 재조회(kt00016 라벨당 1콜).
# 입출금 없는 평시엔 캐시 읽기만 하고 호출 0. 부트스트랩 전이면 needs_refresh가 False.
try:
import investment_principal as ip
if ip.needs_refresh(labels, cash_flow_by_label):
ip.refresh_current(labels, cash_flow_by_label)
except Exception as e:
sys.stderr.write(f'principal refresh failed: {e}\n')
# 미체결 → owner별 + 전역 코드 keyed pending map
# pending_by_owner[owner][code] = {'buy_qty': N, 'sell_qty': M, 'orders': [...]}
# pending_global[code] = {'buy_qty': N, 'sell_qty': M, 'orders': [...]}
@@ -2990,8 +3014,186 @@ def _profit_class(value: int) -> str:
return 'neutral'
# 이 라벨 행 아래에 구분선을 넣는다 (KPI 카드 공용). 라벨 기준이라 순서를 바꿔도 따라간다.
# 총수익 = 전체 요약 묶음(총자산·투자원금·총수익)의 끝.
_KPI_SEP_AFTER = {'총수익'}
def _urlq(s: str) -> str:
"""쿼리스트링용 URL 인코딩 (owner 이름이 한글이라 필요)."""
from urllib.parse import quote
return quote(s, safe='')
def _kpi_more_button(src: str, title: str) -> str:
"""KPI 라벨 옆 [!] 버튼 — kpi-modal 을 열고 src를 fetch해 본문을 innerHTML.
값이 아니라 라벨(th) 쪽에 붙는다 금액 뒤에 두면 숫자 우측정렬이 흐트러진다.
src는 `{'html': '...'}` 주는 엔드포인트 (본문 조립은 서버 , JS는 삽입만).
"""
s = html.escape(src, quote=True)
t = html.escape(title, quote=True)
return (f'<button type="button" class="kpi-more-btn" data-kpi-src="{s}" data-kpi-title="{t}"'
f' title="{t} 상세" aria-label="{t} 상세 보기">!</button>')
def _acct_short(label: str) -> str:
"""모달 안에서는 owner가 이미 정해져 있으니 '가희_' 접두를 떼고 상품만 보인다."""
return label[3:] if label.startswith('가희_') else label
def _render_principal_body(owner: str) -> str:
"""투자원금 팝업 본문 — 요약 + 입출금 내역. 캐시만 읽어 키움 콜 0.
내역은 kt00015 tp='1' 전체인데 대부분이 배당·이자라 실제 입금이 묻힌다
기본은 원금 반영분만 보이고, 체크박스로 나머지까지 펼친다(CSS-only, JS 없음).
"""
import investment_principal as ip
from stock_portfolio_report import derive_owner
import kiwoom_client as kc
labels = [a['label'] for a in kc.list_accounts() if derive_owner(a['label']) == owner]
pr = ip.get_principal(labels)
if pr['principal'] is None:
return '<div class="muted small">투자원금이 아직 집계되지 않았어요.</div>'
fl = ip.get_flows(labels)
def srow(label: str, value: str, cls: str = '') -> str:
return (f'<div class="pm-srow{cls}"><span>{html.escape(label)}</span>'
f'<b>{value}</b></div>')
head = [
srow('투자원금', f'{pr["principal"]:,}', ' strong'),
srow('누적 입금', f'{pr["cash_in"]:,}'),
srow('누적 출금', f'{pr["cash_out"]:,}'),
]
if len(pr['by_label']) > 1:
head.append('<div class="pm-shead">계좌별</div>')
head += [srow(_acct_short(lb), f'{v:,}') for lb, v in pr['by_label'].items()]
# 계좌 간 대체(내 일반↔ISA)는 owner 합계에선 상쇄되지만 계좌별 수치는 밀어놓는다.
# 합계는 정확하니 계좌별만 오해하지 않게 알린다.
if any('대체' in (r['rmrk'] or '') for r in fl['rows']):
head.append('<div class="pm-note pm-note-in">계좌 간 이동(대체)이 있어 계좌별 금액은 '
'실제 외부 입금과 다를 수 있어요. 합계는 정확합니다.</div>')
if pr['qty_flow']:
head.append('<div class="pm-warn">⚠️ 주식 입출고(타사대체) 있음 — '
'현금 기준 원금이 실제보다 작을 수 있어요.</div>')
if not fl['reconciled']:
head.append('<div class="pm-warn">⚠️ 분류되지 않은 새 적요가 있어요 — '
'내역 합계와 원금이 어긋날 수 있습니다.</div>')
if not fl['available']:
return (f'<div class="pm-sum">{"".join(head)}</div>'
f'<div class="muted small pm-empty">입출금 내역은 아직 수집되지 않았어요.</div>')
n_pr = sum(1 for r in fl['rows'] if r['principal'])
n_ot = len(fl['rows']) - n_pr
items = []
for r in fl['rows']:
sign = '+' if r['io'] == 'IN' else ''
acls = 'in' if r['io'] == 'IN' else 'out'
dt = r['date']
dsp = f'{dt[:4]}.{dt[4:6]}.{dt[6:8]}' if len(dt) == 8 else dt
items.append(
f'<div class="pm-row{"" if r["principal"] else " other"}">'
f'<span class="pm-d">{html.escape(dsp)}</span>'
f'<span class="pm-rk">{html.escape(r["rmrk"] or "-")}</span>'
f'<span class="pm-ac">{html.escape(_acct_short(r["label"]))}</span>'
f'<b class="pm-amt {acls}">{sign}{r["amount"]:,}</b>'
f'</div>'
)
other_note = ''
if fl['other_in'] or fl['other_out']:
parts = []
if fl['other_in']:
parts.append(f'입금 {fl["other_in"]:,}')
if fl['other_out']:
parts.append(f'출금 {fl["other_out"]:,}')
other_note = (f'<div class="pm-note">배당·이자 등 {n_ot}건 ({" · ".join(parts)})은 '
f'투자 성과라서 원금에 넣지 않아요.</div>')
return (
f'<div class="pm-sum">{"".join(head)}</div>'
f'{other_note}'
f'<div class="pm-listhead">입출금 내역<span class="muted small"> · 원금 {n_pr}건</span></div>'
f'<input type="checkbox" id="pm-all" class="pm-all-cb">'
f'<label for="pm-all" class="pm-all-lb">배당·이자 등 {n_ot}건 함께 보기</label>'
f'<div class="pm-list">{"".join(items)}</div>'
)
def _render_cashflow_body(owner: str) -> str:
"""당일 입출금 팝업 본문 — 입금/출금 합계 + 건별 내역 (kt00015 tp='1' 당일).
렌더 경로가 이미 부르는 조회지만 팝업은 드물게 열리므로 그때 다시 부른다(계좌당 1).
"""
from stock_portfolio_report import derive_owner
import kiwoom_client as kc
labels = [a['label'] for a in kc.list_accounts() if derive_owner(a['label']) == owner]
by_label = kc.get_cash_flow_all(None, labels)
rows = [{**f, 'label': lb} for lb, fl in by_label.items() for f in (fl or [])]
if not rows:
return '<div class="muted small">오늘 입출금이 없어요.</div>'
rows.sort(key=lambda r: (r.get('time') or '', r['label']))
cash_in = sum(r['amount'] for r in rows if r['io_tp'] == 'IN')
cash_out = sum(r['amount'] for r in rows if r['io_tp'] == 'OUT')
def srow(label: str, value: str, cls: str = '') -> str:
return f'<div class="pm-srow{cls}"><span>{html.escape(label)}</span><b>{value}</b></div>'
head = []
if cash_in:
head.append(srow('입금', f'{cash_in:,}'))
if cash_out:
head.append(srow('출금', f'{cash_out:,}'))
# 순액은 양방향 다 있을 때만 — 한쪽뿐이면 위 합계와 같은 값이다.
if cash_in and cash_out:
net = cash_in - cash_out
head.append(srow('순 입출금', f'{net:+,}', ' strong'))
items = []
for r in rows:
sign = '+' if r['io_tp'] == 'IN' else ''
acls = 'in' if r['io_tp'] == 'IN' else 'out'
tm = (r.get('time') or '')[:5]
items.append(
f'<div class="pm-row">'
f'<span class="pm-d">{html.escape(tm)}</span>'
f'<span class="pm-rk">{html.escape(r.get("rmrk") or "-")}</span>'
f'<span class="pm-ac">{html.escape(_acct_short(r["label"]))}</span>'
f'<b class="pm-amt {acls}">{sign}{r["amount"]:,}</b>'
f'</div>'
)
return (
f'<div class="pm-sum">{"".join(head)}</div>'
f'<div class="pm-listhead">내역<span class="muted small"> · {len(rows)}건</span></div>'
f'<div class="pm-list">{"".join(items)}</div>'
)
def _render_kpi_modal() -> str:
"""KPI 상세 공용 팝업 (투자원금·당일 입출금 등). shell HTML 직속이라 panels swap 영향 없음.
라벨 [!] 클릭 버튼의 data-kpi-src fetch 본문 innerHTML."""
return (
'<div id="kpi-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="kpi-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="kpi-modal-title"><span data-kpi-title>상세</span></div>'
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
'</div>'
'<div class="principal-body" data-kpi-body><div class="muted small">불러오는 중…</div></div>'
'<div class="modal-actions">'
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
'</div>'
'</div>'
'</div>'
)
def _render_owner_kpi(owner: str, d: dict, owner_label_text: str, compact: bool = False) -> str:
"""owner 블록 상단 KPI 카드 (총 평가/손익/예수금/순자산 + 계좌별 예수금).
"""owner 블록 상단 KPI 카드 (총자산/평가금액/예수금 + 매입·손익 + 원금·총수익 + 당일).
compact=True: 자산정보 탭용 당일 매매·당일 실현손익 생략.
"""
@@ -3029,45 +3231,71 @@ def _render_owner_kpi(owner: str, d: dict, owner_label_text: str, compact: bool
deposit_locked_html = (
f'<span class="muted small"> · 매수 묶임 {locked:,}원</span>' if locked > 0 else ''
)
# 구분 없는 단일 목록 (관리자님 지정 순서).
# 라벨 규칙: '총'은 계좌 전체(현금 포함) 기준일 때만 쓴다.
# 총자산 = 평가금액 + 예수금 / 총수익 = 총자산 − 투자원금
# 평가금액·매입금액·평가손익은 보유종목 한정이라 '총'을 붙이지 않는다.
# 맨 위는 계좌 전체 요약 3행 — 총자산(가진 돈) / 투자원금(넣은 돈) / 총수익(그 차이).
# 아래 구분선은 이 묶음 끝(총수익)에 붙는다. 원금 미집계면 뒤 두 행과 선이 함께 사라진다.
principal = d.get('principal')
kpis = [
(' 평가금액', f'{d["total_value"]:,}'),
('총 매입금액', f'{d["total_cost"]:,}'),
('총 평가손익', f'<span class="{pcls}">{sign}{profit:,}원 ({sign}{profit_rate:.2f}%)</span>'),
('당일 평가손익', day_pl_html),
('예수금', f'{deposit_pct_html}{d["deposit"]:,}{deposit_locked_html}'),
('순자산', f'<span class="net">{d["total_net"]:,}원</span>'),
('자산', f'<span class="net">{d["total_net"]:,}</span>'),
]
if principal:
total_ret = d['total_net'] - principal
tcls = _profit_class(total_ret)
tsign = '+' if total_ret >= 0 else ''
tpct = total_ret / principal * 100
# 값은 금액만, 상세는 라벨 옆 [!] 버튼 → 팝업. (3번째 원소 = 라벨 뒤 HTML)
kpis.append(('투자원금', f'{principal:,}',
_kpi_more_button(f'/api/principal?owner={_urlq(owner)}', '투자원금')))
kpis.append(('총수익',
f'<span class="{tcls}">{tsign}{total_ret:,}원 ({tsign}{tpct:.2f}%)</span>'))
# 당일 입출금은 예수금 바로 아래 (현금 잔액 ↔ 그날 현금 이동). 없으면 행 자체가 없다.
cash_in = d.get('cash_in', 0)
cash_out = d.get('cash_out', 0)
cashflow_row = None
if cash_in or cash_out:
net = cash_in - cash_out
ncls = _profit_class(net)
nsign = '+' if net >= 0 else ''
cf_detail = []
if cash_in:
cf_detail.append(f'입금 {cash_in:,}')
if cash_out:
cf_detail.append(f'출금 {cash_out:,}')
cf_detail_html = f'<span class="muted small"> · {" · ".join(cf_detail)}</span>'
kpis.append(('당일 입출금', f'<span class="{ncls}">{nsign}{net:,}원</span>{cf_detail_html}'))
if compact:
kpis.append(('보유 종목', f'{held_count}'))
else:
traded_count = len(d["traded_today"])
kpis.append(('보유종목/당일매매', f'{held_count}개 / {traded_count}'))
# 값은 순액만 — 입금/출금 쪼갠 내역과 건별 목록은 라벨 옆 [!] 팝업으로.
cashflow_row = ('당일 입출금',
f'<span class="{ncls}">{nsign}{net:,}</span>',
_kpi_more_button(f'/api/cashflow?owner={_urlq(owner)}', '당일 입출금'))
kpis += [
('매입금액', f'{d["total_cost"]:,}'),
('평가금액', f'{d["total_value"]:,}'),
('평가손익', f'<span class="{pcls}">{sign}{profit:,}원 ({sign}{profit_rate:.2f}%)</span>'),
('당일 평가손익', day_pl_html),
('예수금', f'{deposit_pct_html}{d["deposit"]:,}{deposit_locked_html}'),
]
if cashflow_row:
kpis.append(cashflow_row)
kpis.append(('보유종목/당일매매', f'{held_count}개 / {len(d["traded_today"])}'))
if not compact:
realized_pl = d.get('realized_pl_total', 0)
realized_fees = d.get('realized_fees_total', 0)
if d.get('traded_today') and (realized_pl or realized_fees):
rcls = _profit_class(realized_pl)
rsign = '+' if realized_pl >= 0 else ''
fee_html = f'<span class="muted small"> · 수수료·세금 {realized_fees:,}원</span>' if realized_fees else ''
realized_html = f'<span class="{rcls}">{rsign}{realized_pl:,}원</span>{fee_html}'
kpis.append(('당일 실현손익', realized_html))
_rtk = {'총 평가금액': 'value', '총 평가손익': 'profit', '순자산': 'net', '당일 평가손익': 'daypl'}
kpis.append(('당일 실현손익',
f'<span class="{rcls}">{rsign}{realized_pl:,}원</span>{fee_html}'))
_rtk ={'평가금액': 'value', '평가손익': 'profit', '총자산': 'net', '당일 평가손익': 'daypl',
'총수익': 'totret'}
_row_list = []
for k, v in kpis:
for i, item in enumerate(kpis):
k, v = item[0], item[1]
lbl_suffix = item[2] if len(item) > 2 else '' # 라벨 뒤 버튼 등 (escape 대상 아님)
attr = f' data-rt-kpi="{_rtk[k]}"' if k in _rtk else ''
_row_list.append(f'<tr><th>{html.escape(k)}</th><td{attr}>{v}</td></tr>')
# 라벨 기준이라 조건부 행이 빠져도 선이 엉뚱한 데 붙지 않는다. 마지막 행엔 안 붙임.
cls = ' class="kpi-sep"' if (k in _KPI_SEP_AFTER and i < len(kpis) - 1) else ''
_row_list.append(
f'<tr{cls}><th>{html.escape(k)}{lbl_suffix}</th><td{attr}>{v}</td></tr>'
)
rows = ''.join(_row_list)
trade_btn_html = ''
if not compact:
@@ -3078,12 +3306,12 @@ def _render_owner_kpi(owner: str, d: dict, owner_label_text: str, compact: bool
f'data-trade-owner="{owner_attr}" data-trade-stock="{label_attr}">'
f'📋 전체 거래내역</button>'
)
return f'''<section class="owner-card" data-rt-owner="{html.escape(owner, quote=True)}">
# data-rt-principal — setOwnerKpis가 순자산 틱마다 총수익을 재계산하는 데 쓴다(0=미집계).
return f'''<section class="owner-card" data-rt-owner="{html.escape(owner, quote=True)}" data-rt-principal="{principal or 0}">
<div class="owner-title-row">
<div class="owner-title">{html.escape(owner_label_text)}</div>
<div class="owner-title">{html.escape(owner_label_text)}<span class="owner-sub">· {html.escape(labels_disp)}</span></div>
{trade_btn_html}
</div>
<div class="owner-sub">{html.escape(labels_disp)}</div>
<table class="kpi">{rows}</table>
</section>'''
@@ -3139,14 +3367,15 @@ def _render_account_kpi(owner: str, label: str, owner_d: dict, balances: dict, j
f'<span class="muted small"> · 매수 묶임 {locked:,}원</span>' if locked > 0 else ''
)
# 라벨·순서는 _render_owner_kpi와 통일 ('총'=현금 포함 전체).
kpis = [
(' 평가금액', f'{total_value:,}'),
('매입금액', f'{total_cost:,}'),
('총 평가손익', f'<span class="{pcls}">{sign}{total_profit:,}원 ({sign}{total_profit_rate:.2f}%)</span>'),
('자산', f'<span class="net">{total_net:,}</span>'),
('매입금액', f'{total_cost:,}'),
('평가금액', f'{total_value:,}'),
('평가손익', f'<span class="{pcls}">{sign}{total_profit:,}원 ({sign}{total_profit_rate:.2f}%)</span>'),
('당일 평가손익', day_pl_html),
('예수금', f'{deposit_pct_html}{deposit:,}{deposit_locked_html}'),
('순자산', f'<span class="net">{total_net:,}원</span>'),
('보유 종목', f'{held_count}'),
('보유종목/당일매매', f'{held_count}개 / {len(traded_today_codes)}'),
]
if traded_today_codes and (realized_pl or realized_fees):
rcls = _profit_class(realized_pl)
@@ -3154,15 +3383,16 @@ def _render_account_kpi(owner: str, label: str, owner_d: dict, balances: dict, j
fee_html = f'<span class="muted small"> · 수수료·세금 {realized_fees:,}원</span>' if realized_fees else ''
kpis.append(('당일 실현손익', f'<span class="{rcls}">{rsign}{realized_pl:,}원</span>{fee_html}'))
rows_html = ''.join(
f'<tr><th>{html.escape(k)}</th><td>{v}</td></tr>' for k, v in kpis
)
_rows = []
for i, (k, v) in enumerate(kpis):
cls = ' class="kpi-sep"' if (k in _KPI_SEP_AFTER and i < len(kpis) - 1) else ''
_rows.append(f'<tr{cls}><th>{html.escape(k)}</th><td>{v}</td></tr>')
rows_html = ''.join(_rows)
sub_text = label_disp if label_disp is not None else label
return f'''<section class="owner-card">
<div class="owner-title-row">
<div class="owner-title">{html.escape(owner_label_text)}</div>
<div class="owner-title">{html.escape(owner_label_text)}<span class="owner-sub">· {html.escape(sub_text)}</span></div>
</div>
<div class="owner-sub">{html.escape(sub_text)}</div>
<table class="kpi">{rows_html}</table>
</section>'''
@@ -4361,7 +4591,7 @@ def _render_net_worth_chart(series: list[tuple[str, int]], current_net: int | No
elif selected_unit == 'year':
empty_msg = '연별 차트는 2년 이상의 데이터가 필요합니다.'
else:
_empty_metric = '기간손익' if per_period else ('손익누적' if is_pnl else '자산')
_empty_metric = '기간손익' if per_period else ('손익누적' if is_pnl else '자산')
empty_msg = f'최근 {_empty_metric} 추이 데이터가 부족합니다.'
def _empty(msg: str) -> str:
@@ -4552,7 +4782,7 @@ def _render_net_worth_chart(series: list[tuple[str, int]], current_net: int | No
else:
title_n = len(series)
_unit_word = {'week': '', 'month': '', 'year': ''}.get(selected_unit, '영업일')
_title_metric = '손익' if per_period else ('손익누적' if is_pnl else '자산')
_title_metric = '손익' if per_period else ('손익누적' if is_pnl else '자산')
title = f'최근 {title_n}{_unit_word} {_title_metric}' + (' · 오늘 잠정' if has_live else '')
# 영역 채우기는 종가 구간만 (실선 영역). 점선 구간은 area 미적용.
@@ -4665,7 +4895,7 @@ def _render_net_worth_chart(series: list[tuple[str, int]], current_net: int | No
svg_parts = [
f'<svg viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid meet" class="net-chart-svg" role="img" '
f'aria-label="자산 추이 {html.escape(range_label)}">',
f'aria-label="자산 추이 {html.escape(range_label)}">',
'<defs>'
f'<linearGradient id="{fill_id}" x1="0" x2="0" y1="0" y2="1">'
f'<stop offset="0%" stop-color="{line_color}" stop-opacity="0.32"/>'
@@ -4750,7 +4980,7 @@ def _render_net_worth_chart(series: list[tuple[str, int]], current_net: int | No
'<div class="tip-date"></div>'
f'<div class="tip-row"><span class="muted">{html.escape(_day_label)}</span><b class="tip-day">—</b></div>'
'<div class="tip-row"><span class="muted">누적 손익</span><b class="tip-total">—</b></div>'
f'<div class="tip-row tip-net-row"{_net_row_hidden}><span class="muted">자산</span><b class="tip-net">—</b></div>'
f'<div class="tip-row tip-net-row"{_net_row_hidden}><span class="muted">자산</span><b class="tip-net">—</b></div>'
# 입출금 행 — 클라이언트 JS가 cf_net 있을 때만 display 토글 (pnl 모드에선 영구 hidden).
f'<div class="tip-row tip-cf-row"{_cf_row_hidden}><span class="muted">입출금</span><b class="tip-cf">—</b></div>'
'</div>'
@@ -5624,7 +5854,7 @@ body.modal-open { position: fixed; left: 0; right: 0; width: 100%; overflow: hid
border-top: 6px solid #1c2230;
}
.stock-name-tip.below::after { top: -6px; bottom: auto; border-top: 0; border-bottom: 6px solid #1c2230; }
.owner-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin-bottom: 2px; }
.owner-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.btn-trades-all { background: #14171f; color: #c9ccd3; border: 1px solid rgba(201,204,211,0.28); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; flex: 0 0 auto; }
.btn-trades-all:hover { background: rgba(201,204,211,0.06); border-color: #6aa9ff; color: #6aa9ff; }
.btn-trades-all:active { transform: translateY(1px); }
@@ -5689,7 +5919,8 @@ section.tab-content { min-height: calc(100dvh - 110px); }
border-radius: 10px;
}
.owner-title { font-size: 15px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em; }
.owner-sub { font-size: 11px; color: #5a5f6c; margin-top: 2px; margin-bottom: 10px; }
/* 계좌 라벨 owner-title 인라인(제목과 ). 하단 여백은 owner-title-row가 갖는다. */
.owner-sub { margin-left: 6px; font-size: 11px; font-weight: 400; letter-spacing: 0; color: #5a5f6c; white-space: nowrap; }
.owner-card table.kpi { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.owner-card table.kpi th {
text-align: left; font-weight: 400; color: #8b8f9a;
@@ -5701,6 +5932,47 @@ section.tab-content { min-height: calc(100dvh - 110px); }
.owner-card table.kpi td .up { color: #ff4d5e; font-weight: 600; }
.owner-card table.kpi td .down { color: #4a8cf0; font-weight: 600; }
.owner-card table.kpi td .neutral { color: #c9ccd3; }
/* KPI 구분선 표시한 아래에 (_KPI_SEP_AFTER: 투자원금·보유종목/당일매매) */
.owner-card table.kpi tr.kpi-sep > th,
.owner-card table.kpi tr.kpi-sep > td { border-bottom: 1px solid #1f2330; padding-bottom: 9px; }
.owner-card table.kpi tr.kpi-sep + tr > th,
.owner-card table.kpi tr.kpi-sep + tr > td { padding-top: 9px; }
/* KPI 라벨 작은 [!] kpi-modal 팝업 (투자원금·당일 입출금) */
.owner-card table.kpi th .kpi-more-btn { display: inline-block; margin-left: 5px; width: 15px; height: 15px; padding: 0; border-radius: 50%; font-size: 10px; font-weight: 700; line-height: 13px; text-align: center; color: #5a5f6c; background: transparent; border: 1px solid rgba(120,124,135,0.35); cursor: pointer; font-family: inherit; vertical-align: middle; }
.owner-card table.kpi th .kpi-more-btn:hover { color: #d6b34a; border-color: rgba(214,179,74,0.55); }
.owner-card table.kpi th .kpi-more-btn:active { transform: translateY(1px); }
/* 투자원금 팝업 */
.principal-body { font-variant-numeric: tabular-nums; }
.pm-sum { padding: 10px 12px; background: #11141c; border: 1px solid #1f2330; border-radius: 8px; }
.pm-srow { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; line-height: 2; }
.pm-srow span { color: #8b8f9a; }
.pm-srow b { color: #d7dae1; font-weight: 500; }
.pm-srow.strong { font-size: 14px; padding-bottom: 4px; margin-bottom: 4px; border-bottom: 1px solid #1f2330; }
.pm-srow.strong span { color: #c9ccd3; }
.pm-srow.strong b { color: #f0f0f0; font-weight: 600; }
.pm-shead { margin-top: 6px; padding-top: 6px; border-top: 1px solid #1f2330; font-size: 10px; color: #6b7080; }
.pm-warn { margin-top: 8px; font-size: 11px; line-height: 1.5; color: #d6b34a; }
.pm-note { margin: 10px 2px 0; font-size: 11px; line-height: 1.55; color: #6b7080; }
.pm-note-in { margin: 6px 0 0; } /* 요약 박스 안쪽 주석 */
.pm-empty { margin-top: 12px; }
.pm-listhead { margin: 14px 2px 8px; font-size: 12px; font-weight: 600; color: #c9ccd3; }
/* 배당·이자 포함 토글 체크박스 + 형제 선택자로 JS 없이 필터 */
.pm-all-cb { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
.pm-all-lb { display: inline-block; margin: 0 2px 8px; padding: 3px 9px; border-radius: 5px; font-size: 11px; color: #5a5f6c; border: 1px dashed rgba(120,124,135,0.30); cursor: pointer; }
.pm-all-lb:hover { color: #d6b34a; border-color: rgba(214,179,74,0.45); }
.pm-all-cb:checked ~ .pm-all-lb { color: #d6b34a; border-color: rgba(214,179,74,0.45); border-style: solid; }
.pm-all-cb:not(:checked) ~ .pm-list .pm-row.other { display: none; }
.pm-list { display: flex; flex-direction: column; max-height: 46dvh; overflow-y: auto; }
.pm-row { display: grid; grid-template-columns: 74px 1fr auto auto; gap: 8px; align-items: baseline; padding: 6px 2px; border-bottom: 1px solid #14171f; font-size: 11px; }
.pm-row.other { opacity: 0.55; }
.pm-row .pm-d { color: #6b7080; }
.pm-row .pm-rk { color: #c9ccd3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pm-row .pm-ac { color: #5a5f6c; font-size: 10px; }
.pm-row .pm-amt { font-weight: 600; white-space: nowrap; }
.pm-row .pm-amt.in { color: #ff4d5e; }
.pm-row .pm-amt.out { color: #4a8cf0; }
.cash-list { list-style: none; margin: 4px 14px 12px; padding: 8px 12px; background: #0d1018; border: 1px solid #14171f; border-radius: 8px; font-size: 12px; color: #c9ccd3; font-variant-numeric: tabular-nums; }
.cash-list li { padding: 2px 0; }
@@ -9874,6 +10146,7 @@ def render_html() -> str:
group_assign_modal_html = _render_group_assign_modal()
group_manage_modal_html = _render_group_manage_modal()
trade_modal_html = _render_trade_modal()
kpi_modal_html = _render_kpi_modal()
info_modal_html = _render_info_modal()
info_desc_modal_html = _render_info_desc_modal()
advice_modal_html = _render_advice_modal()
@@ -10324,6 +10597,24 @@ def render_html() -> str:
'loadInfoSymbols();'
'infoRenderBody();'
'}'
# KPI 상세 공용 팝업 — 서버가 본문 HTML을 만들어 주므로 여기선 innerHTML만.
'function openKpiModal(src,title){'
'var m=document.getElementById("kpi-modal");if(!m||!src)return;'
'var t=m.querySelector("[data-kpi-title]");'
'if(t)t.textContent=title||"상세";'
'var b=m.querySelector("[data-kpi-body]");'
'if(b)b.innerHTML=\'<div class="muted small">불러오는 중…</div>\';'
'm.classList.remove("hidden");m.setAttribute("aria-hidden","false");'
'lockScroll();'
'fetch(src,{credentials:"same-origin"})'
'.then(function(r){return r.json();})'
'.then(function(j){'
'if(!b)return;'
'if(!j||j.error||!j.html){b.innerHTML=\'<div class="muted small">불러오기 실패</div>\';return;}'
'b.innerHTML=j.html;'
'})'
'.catch(function(){if(b)b.innerHTML=\'<div class="muted small">네트워크 오류</div>\';});'
'}'
'function openTradeModal(code,name,owner){'
'var m=document.getElementById("trade-modal");if(!m)return;'
'var ownerMode=!code && !!owner;'
@@ -10571,6 +10862,13 @@ def render_html() -> str:
'openInfoModal(infoBtn.getAttribute("data-info-code")||"",infoBtn.getAttribute("data-info-stock")||"");'
'return;'
'}'
'var prBtn=t.closest&&t.closest(".kpi-more-btn");'
'if(prBtn){'
'e.preventDefault();'
'e.stopPropagation();'
'openKpiModal(prBtn.getAttribute("data-kpi-src")||"",prBtn.getAttribute("data-kpi-title")||"");'
'return;'
'}'
'var infoQ=t.closest&&t.closest(".info-q");'
'if(infoQ){'
'e.preventDefault();'
@@ -12260,6 +12558,11 @@ window.openPinModal = openPinModal;
var td=card.querySelector('[data-rt-kpi="daypl"]');
if(td) td.innerHTML='<span class="'+udcls(T.daypl)+'">'+sgn(T.daypl)+''+(T.dpct!==null?' ('+(T.dpct>=0?'+':'')+T.dpct.toFixed(2)+'%)':'')+'</span>';
}
// 총수익 = 순자산 투자원금. 순자산이 틱마다 움직이니 같이 갱신해야 값이 어긋나지 않는다.
var pr=parseFloat(card.getAttribute('data-rt-principal')||'0');
var tr=card.querySelector('[data-rt-kpi="totret"]');
if(tr&&pr){var v=T.net-pr,p=v/pr*100;
tr.innerHTML='<span class="'+udcls(v)+'">'+sgn(v)+'원 ('+(p>=0?'+':'')+p.toFixed(2)+'%)</span>';}
});
// 순자산 차트 오늘(장중) 포인트 라벨 + SVG ·마지막 선분을 1초마다 함께 이동(라벨 불일치 제거).
document.querySelectorAll('.net-chart-last[data-rt-nw-owner="'+owner+'"][data-rt-nw-live="1"]').forEach(function(el){
@@ -12528,6 +12831,7 @@ window.openPinModal = openPinModal;
{group_manage_modal_html}
{candidate_modal_html}
{trade_modal_html}
{kpi_modal_html}
{info_modal_html}
{info_desc_modal_html}
{advice_modal_html}
@@ -12801,6 +13105,22 @@ class Handler(BaseHTTPRequestHandler):
pass
self._send_json(200, {'connected': connected, 'sub_count': sub_count, 'quotes': quotes, 'vi': vi, 'fill_seq': fill_seq})
return
# KPI 라벨 [!] 팝업 본문 — 서버가 HTML 조립, 클라이언트는 innerHTML만.
if parsed.path in ('/api/principal', '/api/cashflow'):
qs = parse_qs(parsed.query)
owner_q = (qs.get('owner') or [''])[0].strip()
if not owner_q:
self._send_json(400, {'error': 'owner required'})
return
render = (_render_principal_body if parsed.path == '/api/principal'
else _render_cashflow_body)
try:
self._send_json(200, {'html': render(owner_q)})
except Exception as e:
sys.stderr.write(f'{parsed.path} failed: {e}\n')
self._send_json(500, {'error': str(e)})
return
if parsed.path == '/api/trades':
qs = parse_qs(parsed.query)
code = (qs.get('code') or [''])[0].strip()