auto: 일일 백업 2026-07-30 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user