'''
def _profit_class(value: int) -> str:
"""한국 시세 관례: 양수=빨강(up), 음수=파랑(down)."""
if value is None:
return 'neutral'
if value > 0:
return 'up'
if value < 0:
return 'down'
return 'neutral'
def _render_owner_kpi(owner: str, d: dict, owner_label_text: str, compact: bool = False) -> str:
"""owner 블록 상단 KPI 카드 (총 평가/손익/예수금/순자산 + 계좌별 예수금).
compact=True: 자산정보 탭용 — 당일 매매·당일 실현손익 행 생략.
"""
profit = d['total_profit']
profit_rate = d['total_profit_rate']
pcls = _profit_class(profit)
held_count = len([r for r in d['consolidated'] if not r.get('phantom')])
sign = '+' if profit >= 0 else ''
labels_disp = ' + '.join(d['labels'])
day_pl_total = d.get('day_pl_total')
if day_pl_total is None:
# 휴장일/주말은 시장 변동이 없는 정상 상태 — "전날 스냅샷 없음"과 구분된 문구.
if _market_phase_state().get('phase') in ('holiday', 'weekend'):
day_pl_html = '휴장 — 변동 없음'
else:
day_pl_html = '전날 스냅샷 없음'
else:
dcls = _profit_class(day_pl_total)
dsign = '+' if day_pl_total >= 0 else ''
prev_net = d.get('prev_net') or 0
pct_html = ''
if prev_net:
pct = (day_pl_total / prev_net) * 100
pct_html = f' ({dsign}{pct:.2f}%)'
day_pl_html = f'{dsign}{day_pl_total:,}원{pct_html}'
deposit_pct_html = ''
if d['total_value']:
dep_pct = (d['deposit'] / d['total_value']) * 100
deposit_pct_html = f'({dep_pct:.1f}%) '
locked = d.get('pending_buy_locked', 0) or 0
deposit_locked_html = (
f' · 매수 묶임 {locked:,}원' if locked > 0 else ''
)
kpis = [
('총 평가금액', f'{d["total_value"]:,}원'),
('총 매입금액', f'{d["total_cost"]:,}원'),
('총 평가손익', f'{sign}{profit:,}원 ({sign}{profit_rate:.2f}%)'),
('당일 평가손익', day_pl_html),
('예수금', f'{deposit_pct_html}{d["deposit"]:,}원{deposit_locked_html}'),
('순자산', f'{d["total_net"]:,}원'),
]
cash_in = d.get('cash_in', 0)
cash_out = d.get('cash_out', 0)
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' · {" · ".join(cf_detail)}'
kpis.append(('당일 입출금', f'{nsign}{net:,}원{cf_detail_html}'))
if compact:
kpis.append(('보유 종목', f'{held_count}개'))
else:
traded_count = len(d["traded_today"])
kpis.append(('보유종목/당일매매', f'{held_count}개 / {traded_count}개'))
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' · 수수료·세금 {realized_fees:,}원' if realized_fees else ''
realized_html = f'{rsign}{realized_pl:,}원{fee_html}'
kpis.append(('당일 실현손익', realized_html))
rows = ''.join(
f'
{html.escape(k)}
{v}
' for k, v in kpis
)
trade_btn_html = ''
if not compact:
owner_attr = html.escape(owner, quote=True)
label_attr = html.escape(owner_label_text, quote=True)
trade_btn_html = (
f''
)
return f'''
{html.escape(owner_label_text)}
{trade_btn_html}
{html.escape(labels_disp)}
{rows}
'''
def _render_account_kpi(owner: str, label: str, owner_d: dict, balances: dict, journal_by_label: dict, owner_label_text: str, has_prev_snap: bool, label_disp: str | None = None) -> str:
"""단일 계좌 단위 compact KPI 카드. owner_d['rows'] 를 account==label 로 필터해 합산.
합산 뷰의 `_render_owner_kpi` 와 동일한 라벨·순서지만, 데이터 출처가 owner 합 대신 단일 라벨이라
당일 평가손익은 owner-level 전날 스냅샷(holdings는 owner 합)이라 정확한 분해가 불가 →
`(day_change × qty)` 합으로 근사하고 `근사` 표기. 합계 뷰의 prev_net 기반 값은 owner 카드에서 확인.
"""
rows = [r for r in owner_d.get('rows', []) if r.get('account') == label]
total_value = sum(r.get('eval_value', 0) for r in rows)
total_cost = sum(r.get('buy_amount', 0) for r in rows)
total_profit = sum(r.get('profit', 0) for r in rows)
total_profit_rate = (total_profit / total_cost * 100) if total_cost else 0.0
# 종목 단위 unique count (한 라벨 내에선 중복 없지만 안전)
held_count = len({r.get('code') for r in rows if r.get('qty', 0) > 0 and r.get('code')})
bal = balances.get(label) or {}
deposit = bal.get('d2_entra', 0) or 0
total_net = total_value + deposit
locked = bal.get('pending_buy_locked', 0) or 0
# 당일 평가손익 — 라벨별 prev_snap이 없어 근사 (day_change × qty). 실현손익·cash_flow는 포함 안 됨.
if has_prev_snap and rows:
approx_day_pl = sum(int(r.get('day_change', 0) or 0) * int(r.get('qty', 0) or 0) for r in rows)
else:
approx_day_pl = None
# 라벨별 실현손익·당일매매 — journal_by_label[label] 에서 직접 추출
label_journal = journal_by_label.get(label, []) or []
sell_entries = [j for j in label_journal if (j.get('sell_qty') or 0) > 0]
realized_pl = sum(int(j.get('pl_amt', 0) or 0) for j in sell_entries)
realized_fees = sum(int(j.get('cmsn_tax', 0) or 0) for j in sell_entries)
traded_today_codes = {j.get('code') for j in label_journal if ((j.get('buy_qty') or 0) + (j.get('sell_qty') or 0)) > 0 and j.get('code')}
sign = '+' if total_profit >= 0 else ''
pcls = _profit_class(total_profit)
if approx_day_pl is None:
day_pl_html = '전날 스냅샷 없음'
else:
dcls = _profit_class(approx_day_pl)
dsign = '+' if approx_day_pl >= 0 else ''
day_pl_html = f'{dsign}{approx_day_pl:,}원 · 근사'
deposit_pct_html = ''
if total_value:
dep_pct = (deposit / total_value) * 100
deposit_pct_html = f'({dep_pct:.1f}%) '
deposit_locked_html = (
f' · 매수 묶임 {locked:,}원' if locked > 0 else ''
)
kpis = [
('총 평가금액', f'{total_value:,}원'),
('총 매입금액', f'{total_cost:,}원'),
('총 평가손익', f'{sign}{total_profit:,}원 ({sign}{total_profit_rate:.2f}%)'),
('당일 평가손익', day_pl_html),
('예수금', f'{deposit_pct_html}{deposit:,}원{deposit_locked_html}'),
('순자산', f'{total_net:,}원'),
('보유 종목', f'{held_count}개'),
]
if traded_today_codes and (realized_pl or realized_fees):
rcls = _profit_class(realized_pl)
rsign = '+' if realized_pl >= 0 else ''
fee_html = f' · 수수료·세금 {realized_fees:,}원' if realized_fees else ''
kpis.append(('당일 실현손익', f'{rsign}{realized_pl:,}원{fee_html}'))
rows_html = ''.join(
f'
{html.escape(k)}
{v}
' for k, v in kpis
)
sub_text = label_disp if label_disp is not None else label
return f'''
{html.escape(owner_label_text)}
{html.escape(sub_text)}
{rows_html}
'''
def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: str = 'none') -> str:
"""보유종목 자세히 보기용 OHLC mini-table — KRX/NXT 두 거래소 가격·등락률 분리 표시.
각 dict: {open, high, low, price, change_pct} (price=종가/현재가, change_pct=직전 영업일 종가 대비).
한쪽이 None이면 해당 컬럼 전체 '-' placeholder. 4행(현재가/시가/고가/저가) × 2컬럼(KRX/NXT).
active_market: 'krx' / 'nxt' / 'none'. 활성 시장 컬럼은 기본 색, 비활성은 회색 처리.
"""
# price 만 있어도 컬럼 표시 — 거래 없는 시간대도 종가 노출. open/high/low 0 셀은 _cell 안에서 '-' 처리.
has_krx = bool(krx and krx.get('price'))
has_nxt = bool(nxt and nxt.get('price'))
if not (has_krx or has_nxt):
return ''
# NXT 시가 baseline 은 호출처에서 어제 NXT 스냅샷 종가(r['pred_close'])로 미리 박아 전달.
# 자동 baseline 계산(price - change)은 호출처가 못 박은 경우만 fallback.
if has_nxt and nxt and not nxt.get('open'):
nxt_baseline = (nxt.get('price', 0) or 0) - (nxt.get('change', 0) or 0)
if nxt_baseline > 0:
nxt = dict(nxt)
nxt['open'] = nxt_baseline
def _cell(d: dict | None, key: str, dim: bool) -> str:
dim_cls = ' ohlc-dim' if dim else ''
if not d:
return f'-'
if key == 'price':
price = d.get('price', 0)
pct = d.get('change_pct', 0.0) or 0.0
elif key == 'prev_close':
# 전 거래일 종가 — baseline 자체라 등락률 표시 없음.
price = d.get('prev_close', 0)
if not price:
return f'-'
return f'{price:,}원'
else:
price = d.get(key, 0)
base = d.get('price', 0) - d.get('change', 0)
pct = ((price - base) / base * 100) if base else 0.0
if not price:
return f'-'
cls = 'up' if pct > 0 else ('down' if pct < 0 else 'neutral')
sgn = '+' if pct >= 0 else ''
return (
f'{price:,}원'
f'{sgn}{pct:.2f}%'
)
krx_dim = active_market not in ('krx', 'none-active-krx') # 'krx' 활성 또는 양쪽 비활성-krx강조 시만 밝게
nxt_dim = active_market != 'nxt'
# 'none' (양쪽 비활성, 휴장/심야) — 둘 다 dim
if active_market == 'none':
krx_dim = True
nxt_dim = True
elif active_market == 'krx':
krx_dim = False
nxt_dim = True
elif active_market == 'nxt':
krx_dim = True
nxt_dim = False
rows = [
('전일종가', 'prev_close'),
('현재가', 'price'),
('시가', 'open'),
('고가', 'high'),
('저가', 'low'),
]
krx_th_dim = ' ohlc-dim' if krx_dim else ''
nxt_th_dim = ' ohlc-dim' if nxt_dim else ''
parts = [
'
',
'',
f'KRX',
f'NXT',
]
for lbl, key in rows:
parts.append(f'{lbl}')
parts.append(_cell(krx if has_krx else None, key, krx_dim))
parts.append(_cell(nxt if has_nxt else None, key, nxt_dim))
parts.append('
')
return ''.join(parts)
def _render_ohlc_mini(o: int, h: int, l: int, current: int, pred_krx: int, pred_nxt: int) -> str:
"""OHLC 4행 mini-table — 라벨/가격/전일(KRX)/전일(NXT) 4-col grid.
헤더 row 한 줄에 '전일(KRX)' '전일(NXT)' 컬럼명, 데이터 row는 4행(현재가/시가/고가/저가).
pred_krx/pred_nxt 중 하나가 0이면 해당 컬럼은 모두 `-` placeholder — 컬럼 레이아웃 유지.
KRX/NXT 기준이 같은 종목(NXT 미운영)에선 두 컬럼 값이 동일.
"""
if not (o > 0 and h > 0 and l > 0 and current > 0):
return ''
def _pct_cell(p: int, base: int) -> str:
if not base or not p:
return '-'
d = p - base
pct = d / base * 100
cls = 'up' if d > 0 else ('down' if d < 0 else 'neutral')
sgn = '+' if d >= 0 else ''
return f'{sgn}{pct:.2f}%'
rows = [
('현재가', current),
('시가', o),
('고가', h),
('저가', l),
]
parts = [
'
',
'',
'',
'KRX',
'NXT',
]
for lbl, p in rows:
parts.append(f'{lbl}')
parts.append(f'{p:,}원')
parts.append(_pct_cell(p, pred_krx))
parts.append(_pct_cell(p, pred_nxt))
parts.append('
')
return ''.join(parts)
def _render_single_candle(o: int, h: int, l: int, c: int, width: int = 64, height: int = 110) -> str:
"""오늘 하루 단일 캔들 SVG. 시/고/저/현 4값으로 박스+wick. 빨강=상승, 파랑=하락 (한국 관례)."""
if not (o > 0 and h > 0 and l > 0 and c > 0):
return ''
if h == l:
return ''
pad_top, pad_bot = 6, 6
plot_h = height - pad_top - pad_bot
def y(price: int) -> float:
return pad_top + (h - price) / (h - l) * plot_h
cx = width / 2
body_top = y(max(o, c))
body_bot = y(min(o, c))
body_h = max(body_bot - body_top, 1.0)
wick_top = y(h)
wick_bot = y(l)
color = '#ff4d5e' if c >= o else '#4d8cff'
body_w = max(width * 0.55, 14)
body_x = cx - body_w / 2
return (
f''
)
_EMPTY_KV_PAIR = ''
def _interleave_kv_pairs(left: list[str], right: list[str]) -> str:
"""좌·우 dt-dd 쌍 리스트를 4컬럼 row-major grid 순서로 인터리브."""
n = max(len(left), len(right))
out: list[str] = []
for i in range(n):
out.append(left[i] if i < len(left) else _EMPTY_KV_PAIR)
out.append(right[i] if i < len(right) else _EMPTY_KV_PAIR)
return ''.join(out)
def _pending_badges_html(r: dict) -> str:
"""미리보기 💰 배지 — 색만으로 매수(빨강)/매도(파랑) 구분.
수량·상세 정보는 자세히보기(`_pending_detail_html`)에서.
"""
buy = r.get('pending_buy_qty', 0)
sell = r.get('pending_sell_qty', 0)
parts: list[str] = []
if buy:
parts.append(
f'💰'
)
if sell:
parts.append(
f'💰'
)
return ''.join(parts)
def _fmt_hhmmss(raw: str) -> str:
"""ka10075 'tm' 필드 'HHMMSS' → 'HH:MM:SS'. 이미 콜론이거나 비표준이면 그대로."""
s = (raw or '').strip()
if not s or ':' in s:
return s
if len(s) == 6 and s.isdigit():
return f'{s[0:2]}:{s[2:4]}:{s[4:6]}'
return s
def _pending_detail_html(r: dict) -> str:
"""자세히보기용 미체결 ord 단위 상세. 매수/매도 분리 표시.
`r['pending_orders']` 가 ord별 dict 리스트.
레이아웃: dt(라벨, 컬러) / dd 2줄(상단=수량@가격, 하단=계좌·시각·주문번호 muted).
"""
orders = r.get('pending_orders') or []
if not orders:
return ''
buys = [o for o in orders if o.get('side') == 'BUY']
sells = [o for o in orders if o.get('side') == 'SELL']
def _row(o: dict, kind: str) -> str:
qty = o.get('qty', 0)
price = o.get('price', 0)
otype = o.get('order_type') or ''
price_str = f'{price:,}원 지정가' if price else (otype or '시장가')
acc = html.escape(o.get('account', ''))
ord_no = html.escape(o.get('ord_no', ''))
tm = _fmt_hhmmss(html.escape(o.get('time', '')))
meta_parts = [acc]
if tm:
meta_parts.append(tm)
if ord_no:
meta_parts.append(f'주문번호 {ord_no}')
label = '미체결 매수' if kind == 'buy' else '미체결 매도'
return (
f'
{label}
'
f'
'
f'
{qty:,}주 @ {price_str}
'
f'
{" · ".join(meta_parts)}
'
f'
'
)
pairs: list[str] = []
for o in buys:
pairs.append(_row(o, 'buy'))
for o in sells:
pairs.append(_row(o, 'sell'))
return f'
{"".join(pairs)}
'
def _buy_rank_badge(buy_amount: int) -> str:
"""매입 총액 기반 등급 뱃지. 100/200/500/1000만원 경계로 1~5등급. 막대 stack(군대 계급 모티프)."""
if not buy_amount or buy_amount <= 0:
return ''
M = 10_000
if buy_amount <= 100 * M:
tier = 1
elif buy_amount <= 200 * M:
tier = 2
elif buy_amount <= 500 * M:
tier = 3
elif buy_amount <= 1000 * M:
tier = 4
else:
tier = 5
stripes = ''.join('' for _ in range(tier))
title = f'매입 {buy_amount:,}원 · 등급 {tier}'
return f'{stripes}'
def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False, key_suffix: str = '') -> str:
stock = html.escape(r['stock'])
code = html.escape(r.get('code', '') or '')
accounts = html.escape('+'.join(r.get('accounts', [])))
qty = r.get('qty', 0)
avg = r.get('avg', 0)
price = r.get('price', 0)
profit = r.get('profit', 0)
profit_rate = r.get('profit_rate', 0.0)
eval_value = r.get('eval_value', 0)
buy_amount = r.get('buy_amount', 0)
weight = (eval_value / total_value * 100) if total_value else 0.0
pcls = _profit_class(profit)
sign = '+' if profit >= 0 else ''
# 오늘 매매 별표 — 매수/매도 구분. 둘 다 발생 시 두 개 표시.
star_parts: list[str] = []
if r.get('tdy_buyq'):
star_parts.append(f'★')
if r.get('tdy_sellq'):
star_parts.append(f'★')
star = ''.join(star_parts)
day_change = r.get('day_change', 0)
day_change_pct = r.get('day_change_pct', 0.0)
candle_summary = ''
ohlc = r.get('ohlc') or {}
if ohlc:
mini = _render_single_candle(ohlc.get('o', 0), ohlc.get('h', 0), ohlc.get('l', 0), ohlc.get('c', 0), width=24, height=35)
if mini:
candle_summary = f'
{mini}
'
j = r.get('journal') or {}
journal_lines: list[str] = []
summary_journal_html = ''
if j.get('buy_qty'):
journal_lines.append(
f'
'
)
# 가격 출처 마크 — cur_price 가 어느 시장 응답 가격과 일치하는지로 판정.
# NXT 미거래 종목(ohlc_nxt 빈 dict 또는 NXT price==KRX price)은 KRX 마크.
nxt_p = (r.get('ohlc_nxt') or {}).get('price', 0)
krx_p = (r.get('ohlc_krx') or {}).get('price', 0)
if nxt_p and nxt_p == price and nxt_p != krx_p:
mark = 'NXT'
else:
mark = 'KRX'
mark_html = f'{mark}'
# OHLC mini-table — ka10095 NX/NXT batch 결과를 각 컬럼에 분리 표시. 활성 시장만 밝게.
# _nxt_inactive 마커는 stock_portfolio_report의 NX 응답 기반 판정이라 ETF처럼 NXT 정상거래 종목도
# 잘못 잡힘 → 여기선 무시하고 실제 ohlc_nxt 데이터 유무로 표시 결정.
# NXT 컬럼 baseline 을 어제 NXT 스냅샷 종가(r['pred_close']) 로 통일.
# change/change_pct 도 스냅샷 baseline 기준으로 재계산 → cell 안 pct 표시도 일관.
# 전 거래일 종가 행: KRX = pred_close_krx (KRX 전일종가), NXT = r['pred_close'] (어제 NXT 스냅샷).
ohlc_krx_for_render = r.get('ohlc_krx')
_pred_krx = r.get('pred_close_krx', 0) or 0
if ohlc_krx_for_render and _pred_krx > 0:
ohlc_krx_for_render = dict(ohlc_krx_for_render)
ohlc_krx_for_render['prev_close'] = _pred_krx
ohlc_nxt_for_render = r.get('ohlc_nxt')
_snap_prev = r.get('pred_close', 0) or 0
if ohlc_nxt_for_render and _snap_prev > 0:
ohlc_nxt_for_render = dict(ohlc_nxt_for_render)
ohlc_nxt_for_render['open'] = _snap_prev
ohlc_nxt_for_render['prev_close'] = _snap_prev
_nxt_px = ohlc_nxt_for_render.get('price', 0) or 0
ohlc_nxt_for_render['change'] = _nxt_px - _snap_prev
ohlc_nxt_for_render['change_pct'] = ((_nxt_px - _snap_prev) / _snap_prev * 100) if _snap_prev else 0.0
ohlc_mini_html = _render_ohlc_mini_dual(
ohlc_krx_for_render,
ohlc_nxt_for_render,
active_market=mark.lower(),
)
# 미리보기 등락은 rebase 후 day_change_pct 사용 — 어제 NXT 종가 baseline.
# raw pred_close_krx 는 kt00018 결함으로 정규장 시작 전·마감 후 cur_price 와 같은 값이 들어와
# 등락률이 항상 0% 으로 깔리는 문제가 있어 사용 X.
d_pct_val = r.get('day_change_pct')
if show_day_change and isinstance(d_pct_val, (int, float)) and d_pct_val != 0:
d_sign = '+' if d_pct_val >= 0 else ''
d_cls = 'day-pct up' if d_pct_val > 0 else 'day-pct down'
price_html = f'{mark_html}{price:,}{d_sign}{d_pct_val:.2f}%'
else:
price_html = f'{mark_html}{price:,}'
chart_block = ''
if code:
# 카드 펼침 이벤트에 클라이언트 JS 가 /api/chart_svg?code= 를 fetch 해 채움.
# data-chart-code 속성으로 식별. panels swap 후 chartCache 복원도 같은 셀렉터로.
chart_block = f''
left_pairs = [
f'
종목코드
{code}
',
f'
현재가
{price:,}원
',
f'
평단가
{avg:,}원
',
]
right_pairs = [
f'
보유수량
{qty:,}주
',
f'
매입금액
{buy_amount:,}원
',
f'
평가금액
{eval_value:,}원
',
]
dl_inner = _interleave_kv_pairs(left_pairs, right_pairs)
pl_pairs.extend(journal_lines)
pl_dl_inner = ''.join(pl_pairs)
row_key = (code or stock) + key_suffix
tag_add_btn = _tag_add_button_html(r.get('code') or '', r.get('stock') or '')
trade_btn = (
f'
'
f'{tag_add_btn}'
f'{_info_button_html(r.get("code") or "", r.get("stock") or "")}'
f''
f''
f'
'
) if code else ''
tag_chip = _tag_chip_html(r.get('code') or '', r.get('stock') or '', interactive=True)
return f'''
'''
def _render_pending_buy_held_row(r: dict) -> str:
"""보유 종목 중 매수 미체결 걸린 행. 매수등록 섹션 (보유분) 카드.
summary는 매수 대기 합계·평균 주문가, 보유 수량·평단도 함께. detail은 _pending_detail_html이 주문 단위 상세 출력."""
stock = html.escape(r['stock'])
code = html.escape(r.get('code', '') or '')
accounts = html.escape('+'.join(r.get('accounts', [])))
qty = r.get('qty', 0)
avg = r.get('avg', 0)
price = r.get('price', 0)
buy_qty = r.get('pending_buy_qty', 0)
orders = [o for o in (r.get('pending_orders') or []) if o.get('side') == 'BUY']
priced = [o for o in orders if o.get('price', 0) > 0]
if priced:
total = sum(o['qty'] for o in priced)
avg_buy = sum(o['qty'] * o['price'] for o in priced) // total if total else 0
else:
avg_buy = 0
# 매수 주문 평균가 vs 현재가 → 추가매수 거리
if avg_buy and price:
gap = price - avg_buy
gap_pct = (gap / avg_buy * 100) if avg_buy else 0.0
gcls = 'up' if gap > 0 else ('down' if gap < 0 else 'neutral')
gsign = '+' if gap >= 0 else ''
diff_html = f'{gsign}{gap:,.0f}{gsign}{gap_pct:.2f}%'
else:
diff_html = ''
price_html = f'{price:,}' if price else '-'
summary_line2 = f'매수 대기 {buy_qty:,}주'
if avg_buy:
summary_line2 += f' · 평균 주문가 {avg_buy:,}원'
if qty and avg:
summary_line2 += f' · 보유 {qty:,}주 @ {avg:,}'
summary_line2 += ''
chart_block = ''
if code:
chart_block = f''
detail_pairs: list[str] = [f'
종목코드
{code}
']
if price:
detail_pairs.append(f'
현재가
{price:,}원
')
if avg:
detail_pairs.append(f'
평단가
{avg:,}원
')
if avg_buy:
detail_pairs.append(f'
주문 평균가
{avg_buy:,}원
')
if qty:
detail_pairs.append(f'
보유 수량
{qty:,}주
')
detail_pairs.append(f'
매수 대기
{buy_qty:,}주
')
row_key = (code or stock) + ':pending-buy'
return f'''
{stock}매수등록{accounts}
{summary_line2}
{price_html}▾
{f'
{diff_html}
' if diff_html else ''}
{''.join(detail_pairs)}
{_pending_detail_html(r)}
{chart_block}
'''
def _render_pending_sell_held_row(r: dict) -> str:
"""보유 종목 중 매도 미체결 걸린 행. 매도등록 섹션 전용 카드.
summary는 매도 대기 합계·평균 주문가, 보유 수량·평단도 함께. detail은 _pending_detail_html이 주문 단위 상세 출력."""
stock = html.escape(r['stock'])
code = html.escape(r.get('code', '') or '')
accounts = html.escape('+'.join(r.get('accounts', [])))
qty = r.get('qty', 0)
avg = r.get('avg', 0)
price = r.get('price', 0)
sell_qty = r.get('pending_sell_qty', 0)
orders = [o for o in (r.get('pending_orders') or []) if o.get('side') == 'SELL']
priced = [o for o in orders if o.get('price', 0) > 0]
if priced:
total = sum(o['qty'] for o in priced)
avg_sell = sum(o['qty'] * o['price'] for o in priced) // total if total else 0
else:
avg_sell = 0
if avg_sell and avg:
gap = avg_sell - avg
gap_pct = (gap / avg * 100) if avg else 0.0
gcls = 'up' if gap > 0 else ('down' if gap < 0 else 'neutral')
gsign = '+' if gap >= 0 else ''
diff_html = f'{gsign}{gap:,.0f}{gsign}{gap_pct:.2f}%'
else:
diff_html = ''
price_html = f'{price:,}' if price else '-'
summary_line2 = f'매도 대기 {sell_qty:,}주'
if avg_sell:
summary_line2 += f' · 평균 주문가 {avg_sell:,}원'
if qty and avg:
summary_line2 += f' · 보유 {qty:,}주 @ {avg:,}'
summary_line2 += ''
chart_block = ''
if code:
chart_block = f''
detail_pairs: list[str] = [f'
종목코드
{code}
']
if price:
detail_pairs.append(f'
현재가
{price:,}원
')
if avg:
detail_pairs.append(f'
평단가
{avg:,}원
')
if avg_sell:
detail_pairs.append(f'
주문 평균가
{avg_sell:,}원
')
if qty:
detail_pairs.append(f'
보유 수량
{qty:,}주
')
detail_pairs.append(f'
매도 대기
{sell_qty:,}주
')
row_key = (code or stock) + ':pending-sell'
return f'''
{stock}매도등록{accounts}
{summary_line2}
{price_html}▾
{f'
{diff_html}
' if diff_html else ''}
{''.join(detail_pairs)}
{_pending_detail_html(r)}
{chart_block}
'''
def _render_pending_unheld_row(r: dict) -> str:
"""미보유 + 미체결 매수 주문 종목. 보유·당일정산 아닌 신규 매수 대기 행.
summary는 종목명·미체결 합계, detail은 _pending_detail_html이 주문 단위로 출력."""
stock = html.escape(r.get('stock') or r.get('code') or '')
code = html.escape(r.get('code') or '')
buy_qty = r.get('pending_buy_qty', 0)
price = r.get('price')
# 미체결 매수 평균가 (가중평균) — 시장가(price=0)는 제외
orders = [o for o in (r.get('pending_orders') or []) if o.get('side') == 'BUY']
priced = [o for o in orders if o.get('price', 0) > 0]
if priced:
total_qty = sum(o['qty'] for o in priced)
avg_buy = sum(o['qty'] * o['price'] for o in priced) // total_qty if total_qty else 0
else:
avg_buy = 0
accounts = sorted({o.get('account', '') for o in orders if o.get('account')})
acc_text = html.escape('+'.join(accounts))
if isinstance(price, (int, float)) and price:
# 미리보기 등락은 KRX 전일종가 대비 — ka10095의 change_pct 그대로.
day_pct = r.get('day_change_pct')
if isinstance(day_pct, (int, float)):
d_cls = 'day-pct up' if day_pct > 0 else ('day-pct down' if day_pct < 0 else 'day-pct neutral')
d_sign = '+' if day_pct >= 0 else ''
price_html = f'{price:,}{d_sign}{day_pct:.2f}%'
else:
price_html = f'{price:,}'
if avg_buy:
diff = price - avg_buy
dpct = (diff / avg_buy * 100) if avg_buy else 0.0
dcls = 'up' if diff > 0 else ('down' if diff < 0 else 'neutral')
dsign = '+' if diff >= 0 else ''
diff_html = f'{dsign}{diff:,.0f}{dsign}{dpct:.2f}%'
else:
diff_html = ''
else:
price_html = '-'
diff_html = ''
summary_line2 = f'매수 대기 {buy_qty:,}주'
if avg_buy:
summary_line2 += f' · 평균 주문가 {avg_buy:,}원'
summary_line2 += ''
chart_block = ''
if code:
chart_block = f''
detail_pairs: list[str] = [f'
종목코드
{code}
']
if isinstance(price, (int, float)) and price:
detail_pairs.append(f'
현재가
{price:,}원
')
if avg_buy:
detail_pairs.append(f'
주문 평균가
{avg_buy:,}원
')
detail_pairs.append(f'
매수 대기
{buy_qty:,}주
')
row_key = code or stock
return f'''
{stock}매수등록{acc_text}
{summary_line2}
{price_html}▾
{f'
{diff_html}
' if diff_html else ''}
{''.join(detail_pairs)}
{_pending_detail_html(r)}
{chart_block}
'''
def _aggregate_by_unit(series: list[tuple[str, int]], unit: str) -> list[tuple[str, int]]:
"""단위(day/week/month/year)별로 각 구간의 마지막 영업일 점만 남김.
'day'는 그대로 반환. week=ISO week (월~일), month=YYYY-MM, year=YYYY."""
if unit == 'day' or not series:
return series
from datetime import date as _date
groups: dict = {} # group key → (date_str, value)
for d, v in series:
try:
dt = _date.fromisoformat(d)
except Exception:
continue
if unit == 'week':
iso = dt.isocalendar()
key = (iso[0], iso[1])
elif unit == 'month':
key = (dt.year, dt.month)
elif unit == 'year':
key = (dt.year,)
else:
return series
prev = groups.get(key)
if prev is None or d > prev[0]:
groups[key] = (d, v)
return sorted(groups.values(), key=lambda x: x[0])
def _load_cash_flow_by_date(owner: str) -> dict[str, dict]:
"""portfolio_daily_snapshot에서 owner별 일자별 입출금 dict.
{date: {'cash_in', 'cash_out'}} — 둘 다 0인 날은 제외. legacy 스냅샷(cash_in 키 없음)은 자연스럽게 0 처리.
stock_portfolio_report가 매일 20:10 스냅샷 저장 시 누적. 적재 시작 이전 과거 데이터는 비어있음.
"""
if not SNAPSHOT_FILE.exists():
return {}
try:
snap = json.loads(SNAPSHOT_FILE.read_text())
except Exception:
return {}
out: dict[str, dict] = {}
for date_key, day_snap in snap.items():
owner_snap = (day_snap or {}).get(owner) or {}
ci = int(owner_snap.get('cash_in', 0) or 0)
co = int(owner_snap.get('cash_out', 0) or 0)
if ci or co:
out[date_key] = {'cash_in': ci, 'cash_out': co}
return out
def _aggregate_cash_flows_by_unit(daily_cf: dict[str, dict], aggregated_dates: list[str], unit: str) -> dict[str, dict]:
"""unit 단위 집계 시 cash flow 를 series 점에 매핑.
aggregated_dates: 차트 series 의 날짜 리스트 (호출 시점 시리즈).
'day' 또는 daily_cf 빈 dict면 그대로 반환.
week/month/year:
- 그룹에 series 점이 1개(=대표 영업일)면 그 그룹의 daily cash flow 들을 sum 해서 대표 점에 매핑.
- 그룹에 series 점이 여러 개(=_expand_progress_group이 daily 로 펼친 진행 중 그룹)면
각 daily 점에 daily_cf 그대로 매핑 — sum 매핑 시 모든 daily 점이 동일 cf 로 박히는 버그 방지.
"""
if unit == 'day' or not daily_cf:
return dict(daily_cf) if unit == 'day' else {d: dict(cf) for d, cf in daily_cf.items() if d in set(aggregated_dates)}
from datetime import date as _date
def _key(d: str):
dt = _date.fromisoformat(d)
if unit == 'week':
iso = dt.isocalendar()
return (iso[0], iso[1])
if unit == 'month':
return (dt.year, dt.month)
if unit == 'year':
return (dt.year,)
return None
# 그룹별 daily_cf sum (대표 매핑용)
group_sum: dict = {}
for d, cf in daily_cf.items():
try:
k = _key(d)
except Exception:
continue
if k is None:
continue
s = group_sum.setdefault(k, {'cash_in': 0, 'cash_out': 0})
s['cash_in'] += cf.get('cash_in', 0)
s['cash_out'] += cf.get('cash_out', 0)
# series 의 그룹별 점 개수 — 1점=대표, 2점 이상=daily 펼쳐짐.
series_by_group: dict = {}
for d in aggregated_dates:
try:
k = _key(d)
except Exception:
continue
if k is None:
continue
series_by_group.setdefault(k, []).append(d)
out: dict[str, dict] = {}
for k, dates in series_by_group.items():
if len(dates) == 1:
s = group_sum.get(k)
if s and (s['cash_in'] or s['cash_out']):
out[dates[0]] = {'cash_in': s['cash_in'], 'cash_out': s['cash_out']}
else:
for d in dates:
cf = daily_cf.get(d)
if cf and (cf.get('cash_in', 0) or cf.get('cash_out', 0)):
out[d] = {'cash_in': cf.get('cash_in', 0), 'cash_out': cf.get('cash_out', 0)}
return out
def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: str = 'day', with_prev_baseline: bool = False):
"""portfolio_daily_snapshot에서 owner별 순자산 시계열을 추출.
각 날짜의 net_worth = sum(holdings[*].eval_value) + deposit. 누락 필드는 0으로 처리.
unit으로 일/주/월/연 단위 집계 (각 구간 마지막 영업일 점). 기간(days) 필터는 단위 집계 후 적용.
with_prev_baseline=True 면 (series, prev_baseline) 튜플 반환. prev_baseline 은 PnL 모드용으로
series 시작점 직전의 (date, nw) 점 — series[0] 손익도 표시되도록 _to_pnl_series 에 baseline 으로 넘긴다.
series 가 이미 baseline 점을 prepend 한 케이스(기간 잘림 fallback, 진행중 그룹 fallback)에선 None.
"""
if not SNAPSHOT_FILE.exists():
return ([], None) if with_prev_baseline else []
try:
snap = json.loads(SNAPSHOT_FILE.read_text())
except Exception as e:
sys.stderr.write(f'snapshot load failed: {e}\n')
return ([], None) if with_prev_baseline else []
daily_series: list[tuple[str, int]] = []
for date_key in sorted(snap.keys()):
owner_snap = (snap.get(date_key) or {}).get(owner) or {}
# v3/v4: {'holdings': {...}, 'deposit': int}, v1 legacy flat: {종목: {qty, ...}} (holdings/deposit 키 없음).
if 'holdings' in owner_snap:
holdings = owner_snap.get('holdings') or {}
deposit = int(owner_snap.get('deposit', 0) or 0)
else:
holdings = {k: v for k, v in owner_snap.items() if isinstance(v, dict) and 'eval_value' in v}
deposit = 0
if not holdings and not deposit:
continue
eval_total = sum(int(v.get('eval_value', 0) or 0) for v in holdings.values())
daily_series.append((date_key, eval_total + deposit))
aggregated = _aggregate_by_unit(daily_series, unit)
range_start: str | None = None # 기간 필터의 시작 경계 — fallback도 이 안에서만
# filtered 에 baseline 점이 prepend 되었으면 그게 series[0] = PnL baseline 역할 → prev_baseline 별도 안 넘김
baseline_prepended = False
if days == -2: # 이번달 — KST 기준 이번 달 1일 이후만
prefix = datetime.now(KST).strftime('%Y-%m')
range_start = f'{prefix}-01'
filtered = [(d, v) for d, v in aggregated if d.startswith(prefix)]
elif days == -3: # 이번주 — KST 기준 이번 ISO 월요일~오늘
today = datetime.now(KST).date()
monday = (today - timedelta(days=today.weekday())).isoformat()
range_start = monday
filtered = [(d, v) for d, v in aggregated if d >= monday]
elif days == -4: # 올해 — KST 기준 올해 1월 1일 이후
prefix = datetime.now(KST).strftime('%Y')
range_start = f'{prefix}-01-01'
filtered = [(d, v) for d, v in aggregated if d.startswith(prefix)]
elif days < 0: # 전체 — 진행 그룹 daily fallback만 적용 (range 무한)
filtered = list(aggregated)
else:
filtered = list(aggregated[-days:])
# 첫날이 filter 시작점에 막 들어와 1점만 잡힌 케이스 — 직전 영업일 점을 baseline으로 prepend.
# (이번주 월요일·이번달 1일·올해 1월 1일 등에 "데이터 부족" 안내 대신 비교선이 잡히도록.)
# filtered 가 아예 빈 경우(이번주 시작이 휴장·당일 snapshot 적재 전)에도 boundary 기준 직전 점을
# 1개 들고 와야 caller 의 라이브 current_net 과 합쳐 2점 차트가 그려진다.
if len(filtered) < 2:
boundary = filtered[0][0] if filtered else (range_start or '9999-99-99')
baseline = [s for s in aggregated if s[0] < boundary]
if baseline:
if filtered:
filtered = [baseline[-1]] + filtered
else:
filtered = [baseline[-1]]
baseline_prepended = True
# unit이 주/월/연이고도 여전히 1점이면 진행 중 그룹의 daily 점들로 풀어줌.
# (예: 올해+월별 → 5월만 1점 → 5/01~5/18 일별 점들 + 직전 daily 점 baseline)
# range_start로 fallback 범위를 기간 필터 안으로 제한 — 이번주+월별이 5월 전체로 새지 않음.
expanded_raw = _expand_progress_group(filtered, daily_series, unit, range_start=range_start)
# day 단위 차트는 휴장일을 display 시리즈에서 제외 — 시장 무변동이라 점 표시가 무의미.
# baseline 후보(aggregated)엔 그대로 남겨 휴장일의 net_worth 가 직전 baseline 으로 활용 가능 (예: 5/1 → 5/4 손익).
holidays = _load_holidays() if unit == 'day' else set()
expanded = [s for s in expanded_raw if s[0] not in holidays] if holidays else expanded_raw
if not with_prev_baseline:
return expanded
# _expand_progress_group 가 baseline 1점 prepend 한 케이스 — expanded_raw[0] 가 filtered[0] 보다 앞이면 prepend 발생
expand_prepended = bool(expanded_raw and filtered and expanded_raw[0][0] != filtered[0][0])
if baseline_prepended or expand_prepended or not expanded:
return expanded, None
# 정상 case (기간 필터 내 2점 이상) — expanded[0] 직전의 aggregated 점이 PnL baseline
first_dt = expanded[0][0]
prev_candidates = [s for s in aggregated if s[0] < first_dt]
if prev_candidates:
return expanded, prev_candidates[-1]
# 직전 그룹 데이터 없을 때 fallback:
# - day 모드: first_dt holdings 의 buy_total + deposit (=owner 첫 적재일 누적평가손익 baseline)
# - month/year/week 그룹 1개뿐: 그 그룹 안의 첫 영업일 daily 점 (그룹 안 시작→끝 변동 표시).
# ex) 5월만 있는데 본인 4월 empty → 5월 그룹 대표 1점 + 5/1 baseline 으로 한달 추세 그림.
# 2점 이상일 땐 적용 X — series 첫 그룹 대표가 이미 baseline 역할.
if unit == 'day':
first_snap = (snap.get(first_dt) or {}).get(owner) or {}
first_holdings = first_snap.get('holdings') or {}
buy_total = sum(int(h.get('qty', 0) or 0) * int(h.get('avg_price', 0) or 0) for h in first_holdings.values())
first_deposit = int(first_snap.get('deposit', 0) or 0)
if buy_total > 0:
return expanded, (first_dt, buy_total + first_deposit)
elif len(expanded) == 1:
from datetime import date as _date
only_dt = _date.fromisoformat(first_dt)
if unit == 'month':
group_start = f'{only_dt.year:04d}-{only_dt.month:02d}-01'
elif unit == 'week':
group_start = (only_dt - timedelta(days=only_dt.weekday())).isoformat()
elif unit == 'year':
group_start = f'{only_dt.year:04d}-01-01'
else:
group_start = None
if group_start:
group_first = next((s for s in daily_series if group_start <= s[0] < first_dt), None)
if group_first:
return expanded, group_first
return expanded, None
def _to_pnl_series(series: list[tuple[str, int]], cf_by_date: dict | None, prev_baseline_nw: int | None = None) -> tuple[list[tuple[str, int]], int, int]:
"""net_worth 시계열 → '시작 baseline=0' 누적손익 시계열로 변환.
cumulative_pnl[i] = net_worth[i] - net_worth[start] - sum(net_cash_flow[after start..i]).
첫 점은 baseline 이므로 cf 제외(이미 nw[start] 에 반영). cf_by_date 는 series 와 동일 unit 으로
이미 집계된 {date: {cash_in, cash_out}}.
prev_baseline_nw 가 주어지면 series 시작점 직전 영업일의 net_worth 를 baseline 으로 잡아 series[0]
의 손익도 표시된다 (이번달/이번주 기간 필터로 첫날 손익이 baseline=0 으로 깔리는 문제 해결).
cf_by_date 도 series[0] 의 cash flow 부터 누적에 포함된다.
Returns: (pnl_series, baseline_nw, cumulative_cf_total) — current 점 변환에 baseline/cf 필요.
"""
if not series:
return [], 0, 0
cf = cf_by_date or {}
if prev_baseline_nw is not None:
baseline_nw = int(prev_baseline_nw)
cumulative_cf = 0
out: list[tuple[str, int]] = []
for d, nw in series:
delta = cf.get(d) or {}
cumulative_cf += int(delta.get('cash_in', 0) or 0) - int(delta.get('cash_out', 0) or 0)
out.append((d, int(nw) - baseline_nw - cumulative_cf))
else:
baseline_nw = int(series[0][1])
cumulative_cf = 0
out = [(series[0][0], 0)]
for d, nw in series[1:]:
delta = cf.get(d) or {}
cumulative_cf += int(delta.get('cash_in', 0) or 0) - int(delta.get('cash_out', 0) or 0)
out.append((d, int(nw) - baseline_nw - cumulative_cf))
return out, baseline_nw, cumulative_cf
def _expand_progress_group(filtered: list[tuple[str, int]], daily_series: list[tuple[str, int]], unit: str, *, range_start: str | None = None) -> list[tuple[str, int]]:
"""unit이 주이고 series가 1점뿐이면 그 진행 중 그룹의 daily 점들로 대체.
range_start가 주어지면 group_start를 그것보다 뒤로 제한 (기간 필터와 교집합).
그룹 직전 daily 점 1개를 baseline으로 prepend (있으면).
month/year 단위는 daily 펼침 제외 — 단위 의도와 안 맞음. 그룹 1개뿐이면 prev_baseline fallback 으로
그룹 첫 영업일을 baseline 으로 잡아 그룹 안 시작→끝 변동을 2점 차트로 표시."""
if unit in ('day', 'month', 'year') or len(filtered) >= 2 or not filtered or not daily_series:
return filtered
from datetime import date as _date
only_date = filtered[0][0]
only_dt = _date.fromisoformat(only_date)
if unit == 'week':
group_start = (only_dt - timedelta(days=only_dt.weekday())).isoformat()
else:
return filtered
if range_start and range_start > group_start:
group_start = range_start
in_group = [s for s in daily_series if group_start <= s[0] <= only_date]
if len(in_group) < 2:
return filtered
before_group = [s for s in daily_series if s[0] < group_start]
baseline = before_group[-1:] if before_group else []
return baseline + in_group
def _render_net_worth_chart(series: list[tuple[str, int]], current_net: int | None = None, selected_days: int = NET_WORTH_CHART_DAYS, selected_unit: str = NET_WORTH_CHART_UNIT, cash_flow_by_date: dict | None = None, today_cash_flow: dict | None = None, mode: str = NET_WORTH_CHART_MODE, prev_baseline_point: tuple[str, int] | None = None) -> str:
"""꺾은선 SVG (순자산 / 손익누적 공용). 외부 의존성 없음. 데이터 < 2점이면 안내문 반환.
mode='net'(기본): series 는 raw 순자산 시계열. 입출금 마커·툴팁 행 표시.
mode='pnl': series 는 _to_pnl_series 결과(첫 점=0 누적손익). 입출금 효과는 이미 제거,
마커·툴팁 행 모두 숨김. 헤더 라벨도 '손익누적' 으로 분기.
current_net 은 mode에 맞춰 caller가 변환해서 넘긴다(net=순자산, pnl=누적손익).
selected_unit (day/week/month/year)에 따라 X축 라벨 형식과 tooltip 손익 라벨이 분기된다.
prev_baseline_point=(date, nw) 가 주어지면 차트 series 앞에 baseline 점 prepend
→ 첫 영업일 변동이 0 기점에서 분기되는 모양으로 시각화. 그 baseline ↔ 첫 영업일 구간은 점선.
"""
# pnl·period 모두 '입출금 제거 누적손익' 시계열을 입력으로 받는다(cf 마커·순자산 행 숨김 공통).
# pnl=전부 누적 곡선, period=각 점이 그 기간(일/주/월/연) 자체 손익.
is_pnl = mode in ('pnl', 'period')
per_period = (mode == 'period')
cash_flow_by_date = cash_flow_by_date or {}
def _pp_key(ds: str):
from datetime import date as _pp_date
try:
dt = _pp_date.fromisoformat(ds)
except Exception:
return None
if selected_unit == 'week':
iso = dt.isocalendar()
return (iso[0], iso[1])
if selected_unit == 'month':
return (dt.year, dt.month)
if selected_unit == 'year':
return (dt.year,)
return ds # day — 각 날짜가 독립 기간
# baseline 점 prepend — PnL 모드면 baseline 값 = 0 (series 자체가 baseline 대비 누적이라).
# net 모드면 prev nw 그대로.
prev_baseline_nw = prev_baseline_point[1] if prev_baseline_point else None # day/total 계산용
baseline_prepended_pt: tuple[str, int] | None = None
if prev_baseline_point and series:
prev_dt, prev_nw = prev_baseline_point
prev_v_for_chart = 0 if is_pnl else int(prev_nw)
baseline_prepended_pt = (prev_dt, prev_v_for_chart)
# 기간·단위·모드 토글은 자산정보 탭 상단의 공통 select 컨트롤로 이동 (_render_chart_controls).
# 차트 wrapper엔 data-chart-days/unit/mode 박아 swap 후 상태 인식 가능.
if selected_unit == 'month':
empty_msg = '월별 차트는 2개월 이상의 데이터가 필요합니다.'
elif selected_unit == 'week':
empty_msg = '주별 차트는 2주 이상의 데이터가 필요합니다.'
elif selected_unit == 'year':
empty_msg = '연별 차트는 2년 이상의 데이터가 필요합니다.'
else:
_empty_metric = '기간손익' if per_period else ('손익누적' if is_pnl else '순자산')
empty_msg = f'최근 {_empty_metric} 추이 데이터가 부족합니다.'
def _empty(msg: str) -> str:
return (
f'
'
f'
{msg}
'
'
'
)
if len(series) < 2 and current_net is None:
return _empty(empty_msg)
# stock.briefing 정규 적재 시각(20:10 KST) 이전이면 오늘 snapshot 행은 장중 추정치.
# series 끝 행을 제거하고 live 점으로 교체 → 마지막 구간 점선 분기 보존.
# 마감 후엔 confirmed로 간주, snapshot 오늘 행 유지 → 일반 실선.
now = datetime.now(KST)
today_kst = now.strftime('%Y-%m-%d')
before_close = (now.hour, now.minute) < (20, 10)
series = list(series) # 외부 인자 mutate 방지 + 로컬 컷
if before_close and series and series[-1][0] == today_kst:
series = series[:-1]
has_live = (
current_net is not None
and (not series or series[-1][0] != today_kst)
)
# 휴장일/주말은 시장 변동이 없어야 자연 — NXT 가격 보정으로 인한 가짜 점프(=오늘 라이브 점이 어제 스냅샷과 차이) 차단.
if has_live and _market_phase_state().get('phase') in ('holiday', 'weekend'):
has_live = False
# per_period + 라이브 점이 있으면, 라이브와 같은 기간의 확정 점들은 중복이라 제거 (라이브가 그 기간 대표).
# 예: 연별인데 2026 연 대표점(5/29) + 라이브(6/01) 둘 다 2026 → 5/29 버려 baseline→현재 2점으로 그림.
if per_period and has_live and series:
_live_key = _pp_key(today_kst)
while series and _pp_key(series[-1][0]) == _live_key:
series = series[:-1]
# baseline 점이 있으면 series 앞에 prepend (시각화용). day/total tooltip 도 자연스레 baseline 기준이 됨.
has_baseline = baseline_prepended_pt is not None
full_series_with_baseline = ([baseline_prepended_pt] if has_baseline else []) + series
full = full_series_with_baseline + ([(today_kst, int(current_net))] if has_live else [])
if len(full) < 2:
return _empty(empty_msg)
values = [v for _, v in full]
# per_period 라인 = '기간별 손익'(그 기간 자체 손익). values(누적)는 tooltip '누적 손익'·헤더 합계용 유지.
# 각 점 = 그 점 누적값 − 직전 '다른 기간' 점의 누적값 (없으면 baseline 0).
# 직전 '다른 기간' 기준이라, 같은 기간 점이 여러 개여도(올해+월별 진행 중 등) 그 기간 전체 손익이 잡힌다.
# 단일 기간(예: 데이터가 2026 한 해뿐)이면 위에서 라이브 중복점을 정리해 baseline(0)→현재 2점으로 그린다.
if per_period:
_pp_keys = [_pp_key(d) for d, _ in full]
plot_values = []
for _i in range(len(values)):
_base = 0
for _j in range(_i - 1, -1, -1):
if _pp_keys[_j] != _pp_keys[_i]:
_base = values[_j]
break
plot_values.append(int(values[_i]) - _base)
else:
plot_values = values
vmin, vmax = min(plot_values), max(plot_values)
vrange = max(vmax - vmin, 1)
pad = vrange * 0.10
y_lo = vmin - pad
y_hi = vmax + pad
span = max(y_hi - y_lo, 1)
# viewBox 800x280, padding: 좌52 우16 상14 하34 (날짜라벨 + 상단 cf 라벨 영역).
W, H = 800, 360
L, R, T, B = 52, 16, 14, 34
iw = W - L - R
ih = H - T - B
n = len(full)
def x_at(i: int) -> float:
return L + (i / (n - 1)) * iw if n > 1 else L + iw / 2
def y_at(v: int) -> float:
return T + (1 - (v - y_lo) / span) * ih
pts = [(x_at(i), y_at(v)) for i, v in enumerate(plot_values)]
# 3-way path 분리:
# - baseline_dashed_pts: baseline → 첫 영업일 (있는 경우)
# - solid_pts: 첫 영업일 → 마지막 확정 snapshot
# - dashed_pts: 마지막 snapshot → live 점 (있는 경우)
base_offset = 1 if has_baseline else 0
solid_end = len(full) - (1 if has_live else 0)
baseline_dashed_pts = pts[:base_offset + 1] if has_baseline else []
solid_pts = pts[base_offset:solid_end]
dashed_pts = pts[solid_end - 1:] if has_live else []
# per_period면 헤더·라인색을 '마지막 기간 손익' 기준으로 (그 외 pnl/net은 시작 대비 변동).
delta = plot_values[-1] if per_period else values[-1] - values[0]
up = delta >= 0
line_color = '#ff4d5e' if up else '#4a8cf0'
fill_id = f'nw-grad-{"up" if up else "dn"}'
delta_sign = '+' if delta >= 0 else '−'
if is_pnl:
# per_period=마지막 기간 손익, 아니면 마지막 누적 손익. (pnl 첫 점=0 baseline → 비율 무의미)
_hdr_val = plot_values[-1] if per_period else values[-1]
delta_label = f'{delta_sign}{abs(_hdr_val):,}원'
else:
delta_pct = (delta / values[0] * 100) if values[0] else 0.0
delta_label = f'{delta_sign}{abs(delta):,}원 ({delta_sign}{abs(delta_pct):.2f}%)'
delta_class = 'up' if up else 'down'
# 데이터 범위(vmin~vmax)를 3구간으로 균등 분할 → 가로선 4개 (vmax / +2/3 / +1/3 / vmin).
grid_vals = [vmax - vrange * i / 3 for i in range(4)]
grid_lines = []
for gv in grid_vals:
gy = y_at(int(gv))
grid_lines.append(f'')
label = f'{int(gv) // 1_000_000:,}M' if abs(gv) >= 1_000_000 else f'{int(gv) // 1000:,}K'
grid_lines.append(f'{html.escape(label)}')
# 0원 기준선 — 손익 0 높이가 차트 범위 안일 때만 (어디부터 적자/흑자인지 표시). 순자산은 항상 양수라 자동 미표시.
# 기준선(가로선)은 유지하고 '0' 텍스트 라벨만 제거 (관리자님 요청 — M/K 라벨과 겹쳐 보기 번잡).
zero_line = ''
if y_lo <= 0 <= y_hi:
zy = y_at(0)
zero_line = (
f''
)
label_idx = sorted({0, n // 2, n - 1})
# 점들이 모두 같은 그룹(month/year)이면 라벨 중복이라 한 단계 작은 단위로 fallback.
# 라벨 형식은 unit 그대로 — year=YYYY, month=YYYY-MM, week/day=MM-DD.
# 한 단위 안에 모든 점이 들어가는 케이스(이번달·올해 진행 중)도 unit 라벨 유지 → 중복 라벨 보일 수 있으나 사용자 의도(단위 명확 표시) 우선.
def _x_label_for(date_iso: str) -> str:
if selected_unit == 'year':
return date_iso[:4]
if selected_unit == 'month':
return date_iso[:7]
return date_iso[5:]
x_labels = []
for i in label_idx:
date_str = _x_label_for(full[i][0])
if has_live and i == n - 1:
date_str += '*' # 장중 잠정 표시
x_labels.append(
f'{html.escape(date_str)}'
)
last_x, last_y = pts[-1]
# per_period면 마지막 점 = 마지막 기간 손익(=마지막 dot 높이와 일치). 아니면 누적 마지막값.
last_v = plot_values[-1] if per_period else values[-1]
if is_pnl:
last_sign = '+' if last_v >= 0 else '−'
last_label_v = f'{last_sign}{abs(last_v):,}원'
else:
last_label_v = f'{last_v:,}원'
if has_live:
last_label_v += ' (장중)'
range_label = f'{_x_label_for(full[0][0])} ~ {_x_label_for(full[-1][0])}'
# per_period는 baseline 제외 실제 표시 기간 수(라이브 포함). 라이브 중복점 제거로 series가 비어도 정확.
if per_period:
title_n = len({k for k in _pp_keys[base_offset:] if k is not None})
else:
title_n = len(series)
_unit_word = {'week': '주', 'month': '월', 'year': '연'}.get(selected_unit, '영업일')
_title_metric = '손익' if per_period else ('손익누적' if is_pnl else '순자산')
title = f'최근 {title_n}{_unit_word} {_title_metric}' + (' · 오늘 잠정' if has_live else '')
# 영역 채우기는 종가 구간만 (실선 영역). 점선 구간은 area 미적용.
area_d = ''
if len(solid_pts) >= 2:
area_d = (
f'M {solid_pts[0][0]:.2f},{T + ih:.2f} '
+ 'L ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in solid_pts)
+ f' L {solid_pts[-1][0]:.2f},{T + ih:.2f} Z'
)
solid_d = ''
if len(solid_pts) >= 2:
solid_d = 'M ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in solid_pts)
dashed_d = ''
if len(dashed_pts) >= 2:
dashed_d = 'M ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in dashed_pts)
baseline_dashed_d = ''
if len(baseline_dashed_pts) >= 2:
baseline_dashed_d = 'M ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in baseline_dashed_pts)
last_dot_class = 'last-dot live' if has_live else 'last-dot'
# 호버/터치 인터랙션용 메타. 각 포인트의 viewBox 좌표·날짜·순자산·그날 손익·누적 손익·입출금.
base_v = values[0]
prev_v: int | None = None
pts_meta: list[dict] = []
today_kst_str = today_kst # 위에서 산출됨
for i, (d_str, v_i) in enumerate(full):
x_i, y_i = pts[i]
cf = cash_flow_by_date.get(d_str)
# 오늘 라이브 포인트는 별도 today_cash_flow 우선 — snapshot 적재 전이라도 KPI와 일치하게.
if d_str == today_kst_str and today_cash_flow and (today_cash_flow.get('cash_in', 0) or today_cash_flow.get('cash_out', 0)):
cf = {'cash_in': int(today_cash_flow.get('cash_in', 0) or 0),
'cash_out': int(today_cash_flow.get('cash_out', 0) or 0)}
# PnL 모드: v 자체가 이미 baseline 대비 누적손익 → total=v_i (재차감 X).
# day 는 직전 점 대비 변동분이고, 첫 점은 baseline 대비라 v_i 자체가 첫날 손익 의미 (0 아님).
# net 모드: v 가 raw 순자산. prev_baseline_nw 가 있으면 첫 점 day/total 도 그 대비 (휴장일 baseline 등).
if is_pnl:
# per_period면 '그 기간 손익' 행을 plot_values 와 일치시킨다(라이브점=이번 달 전체 손익).
# 아니면(day) 직전 점 대비 변동분. total 은 항상 누적값.
day_val = int(plot_values[i]) if per_period else (int(v_i - prev_v) if prev_v is not None else int(v_i))
total_val = int(v_i)
else:
if prev_v is not None:
day_val = int(v_i - prev_v)
elif prev_baseline_nw is not None:
day_val = int(v_i - prev_baseline_nw)
else:
day_val = 0
total_val = int(v_i - prev_baseline_nw) if prev_baseline_nw is not None else int(v_i - base_v)
meta = {
'd': d_str,
'v': int(v_i),
'day': day_val,
'total': total_val,
'x': round(x_i, 2),
'y': round(y_i, 2),
}
if cf and (cf.get('cash_in', 0) or cf.get('cash_out', 0)):
ci_i = int(cf.get('cash_in', 0) or 0)
co_i = int(cf.get('cash_out', 0) or 0)
meta['cf_in'] = ci_i
meta['cf_out'] = co_i
meta['cf_net'] = ci_i - co_i
pts_meta.append(meta)
prev_v = v_i
pts_attr = html.escape(json.dumps(pts_meta, ensure_ascii=False), quote=True)
# 입출금 수직선 — 해당 포인트 x좌표에 차트 영역을 가로지르는 dashed 선.
# 색상: net > 0 입금 우세 = #0a7 / net < 0 출금 우세 = #d33 / 0 (= 입+출 동일) = #888.
# 작은 캡션 라벨(±N) 을 차트 상단에 살짝 띄움 — 텍스트로 명확히 표시.
cf_markers: list[str] = []
for meta in pts_meta:
if 'cf_net' not in meta:
continue
cf_x = meta['x']
cf_net = meta['cf_net']
cf_color = '#0a7' if cf_net > 0 else ('#d33' if cf_net < 0 else '#888')
cf_sign = '+' if cf_net >= 0 else '−'
cf_abs = abs(cf_net)
cf_label = f'{cf_sign}{cf_abs // 1_000_000}M' if cf_abs >= 1_000_000 else (
f'{cf_sign}{cf_abs // 1000}K' if cf_abs >= 1000 else f'{cf_sign}{cf_abs}'
)
cf_markers.append(
f''
)
cf_markers.append(
f'{html.escape(cf_label)}'
)
# per_period: 각 기간 점마다 세로 구분선 + 작은 마커 — 기간별 손익이 비슷해 라인이 평평해도 기간 경계를 읽을 수 있게.
# (누적/순자산은 연속 곡선이라 구분선 불필요 — period 모드만)
period_seps = []
point_dots = []
for _i, (px, py) in enumerate(pts):
if per_period:
# 구분선은 기간손익 전용. 점 색 = 그 기간 손익 부호 (이익=빨강 / 손실=파랑 / 0=회색).
period_seps.append(
f''
)
_pv = plot_values[_i]
_dot_color = '#ff4d5e' if _pv > 0 else ('#4a8cf0' if _pv < 0 else '#6b7280')
else:
# 순자산·손익누적은 연속 곡선 — 점만 라인색으로 (부호색은 순자산이 항상 양수라 무의미).
_dot_color = line_color
point_dots.append(f'')
svg_parts = [
f'')
_day_label = _NET_WORTH_DELTA_LABEL.get(selected_unit, '그날 손익')
# pnl 모드는 '순자산'·'입출금' 행 의미 없음 (입출금 효과 이미 제거). 서버에서 hidden 처리.
_net_row_hidden = ' hidden' if is_pnl else ''
_cf_row_hidden = ' hidden' if is_pnl else ' hidden' # cf-row 는 net 모드라도 cf_net 있을 때만 JS가 노출
tip_html = (
'
'
''
''
f'
{html.escape(_day_label)}—
'
'
누적 손익—
'
f'
순자산—
'
# 입출금 행 — 클라이언트 JS가 cf_net 있을 때만 display 토글 (pnl 모드에선 영구 hidden).
f'
'
)
held = [r for r in d['consolidated'] if not r.get('phantom')]
traded_held = [r for r in held if r.get('traded_today')]
phantoms = [r for r in d['traded_today'] if r.get('phantom')]
if traded_held or phantoms:
parts.append(
f'
당일 매매{len(traded_held) + len(phantoms)}
'
)
for r in sorted(phantoms, key=lambda x: -((x.get('journal') or {}).get('pl_amt', 0))):
parts.append(_render_phantom_row(r))
for r in sorted(traded_held, key=lambda x: -x.get('eval_value', 0)):
parts.append(_render_holding_row(r, d['total_value'], show_day_change))
pending_unheld = d.get('pending_unheld') or []
pending_buy_held = [r for r in (d.get('consolidated') or [])
if not r.get('phantom') and r.get('pending_buy_qty', 0) > 0]
buy_total = len(pending_unheld) + len(pending_buy_held)
if buy_total:
parts.append(
f'
매수등록{buy_total}
'
)
for r in sorted(pending_buy_held, key=lambda x: -x.get('pending_buy_qty', 0)):
parts.append(_render_pending_buy_held_row(r))
for r in pending_unheld:
parts.append(_render_pending_unheld_row(r))
pending_sell_held = [r for r in (d.get('consolidated') or [])
if not r.get('phantom') and r.get('pending_sell_qty', 0) > 0]
if pending_sell_held:
parts.append(
f'
매도등록{len(pending_sell_held)}
'
)
for r in sorted(pending_sell_held, key=lambda x: -x.get('pending_sell_qty', 0)):
parts.append(_render_pending_sell_held_row(r))
# 당일매매 발생한 종목도 보유 종목 섹션에 그대로 표시 — 양쪽 다 보이게 함.
# 같은 종목이 양쪽에 들어가니 row_key에 ':held' suffix를 붙여 mutex/open 복원 충돌 방지.
# 합산 / 각계좌별도 토글:
# - 합산: consolidated 행 (현재 동작 유지)
# - 각계좌별도: raw rows를 평가금액 desc로 평탄 정렬, account 라벨은 각 행에 표시
# 토글은 자산정보 탭과 in-memory state 공유 → 한쪽 토글하면 다른 쪽도 같이 바뀜.
raw_rows = d.get('rows') or []
# ohlc 같은 owner-level annotation을 raw row에 코드 단위로 전파.
ohlc_by_code = {
c.get('code'): c.get('ohlc') for c in d.get('consolidated', [])
if c.get('code') and c.get('ohlc')
}
if held or raw_rows:
consolidated_rows_html: list[str] = []
for r in sorted(held, key=lambda x: -x.get('eval_value', 0)):
consolidated_rows_html.append(
_render_holding_row(r, d['total_value'], show_day_change, key_suffix=':held')
)
by_account_rows_html: list[str] = []
for r in sorted(raw_rows, key=lambda x: -x.get('eval_value', 0)):
if r.get('qty', 0) <= 0:
continue
synth = dict(r)
synth['accounts'] = [r.get('account', '')]
synth['traded_today'] = (r.get('tdy_buyq', 0) + r.get('tdy_sellq', 0)) > 0
if r.get('code') in ohlc_by_code:
synth['ohlc'] = ohlc_by_code[r['code']]
acc_suffix = (r.get('account', '') or '').replace(':', '_')
by_account_rows_html.append(
_render_holding_row(synth, d['total_value'], show_day_change, key_suffix=f':held:{acc_suffix}')
)
held_count = len(held)
acc_count = sum(1 for r in raw_rows if r.get('qty', 0) > 0)
# 토글 버튼은 owner KPI 카드 상단 (전체 거래내역 옆)으로 이동 — panes만 여기에 prerender.
parts.append(
'
'
'
'
f'
보유 종목{held_count}
'
+ ('\n'.join(consolidated_rows_html) if consolidated_rows_html else '
보유 종목 데이터가 없습니다.
')
+ '
'
'
'
f'
보유 종목{acc_count}
'
+ ('\n'.join(by_account_rows_html) if by_account_rows_html else '
'
f''
# 단위·모드 한 cc-field 묶음 — 좁은 화면 줄바꿈 방지 + 시각적 인접 배치.
f''
'
'
)
def _format_signed_billion(v: int | None) -> tuple[str, str]:
"""투자자 매매 금액 포맷 — (표시문자열, direction 클래스). 단위는 억원."""
if v is None:
return '—', 'flat'
if v > 0:
return f'+{v:,}', 'up'
if v < 0:
return f'{v:,}', 'down'
return '0', 'flat'
def _adr_class(adr: float | None) -> tuple[str, str]:
"""ADR 색상·해석 라벨. 통상 ≤75 침체(매수권), ≥125 과열(매도권).
20일 이동평균 기준 — 단일일 raw ratio는 변동성이 커서 임계치 해석이 무의미."""
if adr is None:
return 'flat', ''
if adr <= 75:
return 'down', '침체'
if adr >= 125:
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 누적 후엔 자동 중복)."""
base: list[dict] = [
{'date': r['date'], 'adr': float(r['adr']), '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})
return base
def _compute_adr_ma_latest(symbol: str, today_raw_adr: float | None, today_date: str | 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")."""
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)
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)
if len(used) < window:
return (None, len(used), used_live)
avg = sum(r['adr'] for r in used) / len(used)
return (avg, 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 채워지지 않은 앞쪽은 skip.
is_live는 그 시점 윈도우에 라이브 점이 포함됐는지 (= 마지막 평균만 라이브일 가능성)."""
series: list[tuple[str, float, bool]] = []
for i in range(window - 1, len(rows)):
win = rows[i + 1 - window : i + 1]
avg = sum(r['adr'] for r in win) / window
is_live = any(r.get('is_live') for r in win)
series.append((rows[i]['date'], avg, is_live))
return series
def _render_adr_summary_block(rows: list[dict] | None) -> str:
"""ADR 20일 이평 표 + 임계치 hint. ADR 추세 카드 상단 mini section.
rows가 비면 안내 텍스트로 graceful degrade — 차트 카드 자체는 계속 그려진다."""
if not rows:
return (
'
'
+ '
시장 지표 미수신
'
+ '
'
)
body_rows: 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)
adr_cls, adr_tag = _adr_class(ma)
if ma is not None:
adr_str = f'{ma:.1f}'
sub_html = '
20일 이평
'
else:
adr_str = '—'
sub_html = f'
누적 중 {count}/{ADR_MA_WINDOW}
'
tag_html = f' {adr_tag}' if adr_tag else ''
body_rows.append(
f'
'
)
def _render_market_indicators_card() -> str:
"""자산정보 탭의 시장정보 sub-tab에 들어가는 오늘의 시장 카드.
- 지수 섹션: 미국 정규장(ET 09:30~16:00) 시간이면 KOSPI 200 + MSCI Korea(EWY), 그 외 KOSPI/KOSDAQ 등락률.
- 투자자별 매매: 시장 단위 (개인/외국인/기관) — 네이버 m.stock 비공식 API.
실패시 에러 카드."""
rows = _fetch_market_indicators()
if not rows:
return '
시장 지표를 불러올 수 없습니다.
'
# bizdate는 두 시장 동일 — 첫 행 기준으로 헤더 라벨링.
bizdate = next((r['bizdate'] for r in rows if r.get('bizdate')), None)
if bizdate and len(bizdate) == 8:
date_label = f'{bizdate[4:6]}/{bizdate[6:8]}'
else:
date_label = ''
# === 지수 섹션 (시간 무관 상시 표시) ===
# KOSPI / KOSDAQ (라이브 또는 종가, _fetch_indices) + MSCI Korea(EWY) + S&P 500 + 필라델피아 반도체(SOX).
# KOSPI 200 은 라이브 데이터 부재로 KOSPI 와 거의 동일 → KOSPI 만 표시.
all_indices = _fetch_indices()
indices_by_label = {q.get('label'): q for q in all_indices if q.get('label')}
extra = _fetch_extra_market_quotes() or [None] * 4
extra = (extra + [None] * 4)[:4]
ewy, sox, vix, tnx = extra
index_quotes = [
indices_by_label.get('KOSPI'),
indices_by_label.get('KOSDAQ'),
indices_by_label.get('S&P500'),
sox,
ewy,
vix,
tnx,
]
index_hint = '국내·미국 주요 지수'
index_descriptions = {
'KOSPI': (
'한국 유가증권시장에 상장된 보통주 전체로 산출하는 시가총액 가중 종합지수.\n\n'
'• 1980년 1월 4일 = 100 기준\n'
'• 약 800개 종목, 시총 약 2,500조원 규모\n'
'• 한국 경제·대형주 sentiment 대표 지표\n'
'• 외국인·기관 자금 흐름이 큰 영향\n\n'
'거래시간 (KST)\n'
'• 정규장: 09:00 ~ 15:30\n'
'• NXT 야간: 08:00~09:00 + 15:30~20:00'
),
'KOSDAQ': (
'한국 코스닥시장 종합지수 — 중소·벤처·기술주 중심.\n\n'
'• 1996년 7월 1일 = 1000 기준\n'
' (2004년 100 → 1000 재조정)\n'
'• 약 1,700개 종목\n'
'• 바이오·IT·2차전지 비중이 높음\n'
'• 글로벌 기술주(나스닥·SOX) sentiment에 민감\n'
'• 코스피보다 변동성·개인 비중이 큼'
),
'S&P500': (
'Standard & Poor\'s가 1957년 도입한 미국 대형주 500개 시가총액 가중 지수.\n\n'
'• 미국 상장 기업 시총의 약 80% 커버\n'
'• 기관 투자자 표준 벤치마크\n'
'• SPY ETF 시총 약 5,000억 달러\n\n'
'거래시간\n'
'• 정규장 ET 09:30 ~ 16:00\n'
'• KST 22:30 ~ 05:00 (DST)\n\n'
'한국 시장 다음날 개장 sentiment에 가장 큰 영향.'
),
'필라델피아 반도체': (
'PHLX Semiconductor Sector Index (^SOX) — 1993년 도입.\n\n'
'• 미국 상장 반도체 30개 종목 시총 가중\n'
'• Nvidia · TSMC ADR · Intel · AMD\n'
' ASML · Broadcom · Qualcomm 등 포함\n\n'
'한국 삼성전자·SK하이닉스와 0.7~0.8 상관관계.\n'
'글로벌 반도체 사이클 선행 지표로\n'
'한국 IT 비중이 큰 코스피에 직접 영향.'
),
'MSCI Korea (EWY)': (
'iShares MSCI South Korea ETF (NYSE 상장).\n\n'
'• MSCI Korea Index 추종\n'
'• 한국 시총 상위 large/mid cap 약 90종목 보유\n'
'• 삼성전자·SK하이닉스·LG에너지솔루션\n'
' 현대차 등 대표 종목 포함\n\n'
'미국 거래소 상장이라\n'
'미국 정규장 시간(KST 22:30~05:00 DST)에 라이브 거래.\n'
'ADR·대표주의 미국 야간 등락이 EWY에 반영되어\n'
'한국 시장 야간 sentiment 추정에 활용.'
),
'VIX (공포지수)': (
'CBOE Volatility Index — 1993년 도입.\n'
'S&P 500 옵션의 30일 implied volatility를 연율화한 변동성 지수.\n'
'"공포지수(Fear Gauge)" 별칭.\n\n'
'구간 해석\n'
'• 12 이하: 안일 (complacency)\n'
'• 12 ~ 20: 안정\n'
'• 20 ~ 30: 주의\n'
'• 30 ~ 40: 공포 (시장 패닉)\n'
'• 40 이상: 극단 위기 (2008·2020 수준)\n\n'
'VIX 상승 = 위험자산 회피\n'
'→ 한국 외국인 매도 압력 증가.'
),
'미국채 10년 (%)': (
'미국 10년 만기 국채(Treasury Note)의 연 수익률.\n\n'
'연준 통화정책 · 인플레이션 기대 · 재정 적자가 반영되는\n'
'글로벌 무위험 금리 기준.\n\n'
'한국 시장 영향\n'
'• 상승 → 자금이 채권으로 이동\n'
' → 한국 외국인 매도 압력 (위험자산 회피)\n'
'• 하락 → 신흥국·한국 자금 유입 가능성 ↑\n\n'
'구간 해석\n'
'• 통상 3.5 ~ 5%대가 정상\n'
'• 6% 이상은 시장 부담\n'
'• 단기 금리와의 역전(yield curve inversion)은\n'
' 경기 침체 신호로 해석'
),
}
index_rows: list[str] = []
for q in index_quotes:
if not q:
continue
direction = q.get('direction') or 'flat'
sign = '+' if direction == 'up' else ('−' if direction == 'down' else '')
price = q.get('price', '—')
change = q.get('change', '0')
pct = q.get('pct', '0.00')
label = q.get('label', '')
desc = index_descriptions.get(label)
# 색상 — 양봉=빨강(.up), 음봉=파랑(.down), 보합=회색(.flat). market-table CSS 그대로 사용.
# 설명 — ⓘ 버튼 클릭 시 idx-info-modal 팝업으로 표시. 가격/등락도 attr로 전달해 팝업 안에서 함께 표시.
# 차트는 data-idx-symbol 가 있으면 모달에서 lazy fetch (/api/idx-chart?symbol=...) 후 SVG 렌더.
symbol_attr = _IDX_CHART_SYMBOL.get(label, '')
info_btn = (
f''
) if desc else ''
index_rows.append(
f'
'
)
deal_rows: list[str] = []
for r in rows:
p_str, p_cls = _format_signed_billion(r.get('personal'))
f_str, f_cls = _format_signed_billion(r.get('foreign'))
i_str, i_cls = _format_signed_billion(r.get('institutional'))
deal_rows.append(
f'
'
f'
{html.escape(r["label"])}
'
f'
{p_str}
'
f'
{f_str}
'
f'
{i_str}
'
f'
'
)
date_suffix = f' {date_label}' if date_label else ''
refresh_btn = (
''
)
return (
'
'
f'
오늘의 시장{date_suffix}{refresh_btn}
'
+ index_section +
'
'
'
투자자별 매매순매수 금액 · 단위 억원
'
'
'
'
개인
외국인
기관
'
f'{"".join(deal_rows)}'
'
'
'
'
'
'
)
def _load_market_history(days: int = ADR_TREND_DAYS) -> dict[str, list[dict]]:
"""state/market_indicators_history.jsonl에서 시장별 최근 days 행 로드.
days=-1 → 전체. 반환: {'KOSPI': [{...오래된 순...}], 'KOSDAQ': [...]}. 파일 없거나 깨지면 빈 dict."""
if not MARKET_HISTORY_FILE.exists():
return {}
try:
rows: list[dict] = []
for ln in MARKET_HISTORY_FILE.read_text().splitlines():
ln = ln.strip()
if not ln:
continue
try:
rows.append(json.loads(ln))
except json.JSONDecodeError:
continue
except Exception as e:
sys.stderr.write(f'market history load failed: {e}\n')
return {}
grouped: dict[str, list[dict]] = {}
for r in rows:
m = r.get('market')
if not m:
continue
grouped.setdefault(m, []).append(r)
for m in grouped:
grouped[m].sort(key=lambda r: r.get('date', ''))
if days >= 0 and len(grouped[m]) > days:
grouped[m] = grouped[m][-days:]
return grouped
def _render_adr_trend_controls(selected_days: int) -> str:
"""ADR 추세 카드 상단 기간 버튼 그룹. localStorage(behive.adrTrendDays) 키로 보존.
클릭 시 클라이언트 JS의 document-level handler가 받아서 localStorage 저장 + load(fresh) 트리거."""
btns = ''.join(
f''
for lbl, d in ADR_TREND_PRESETS
)
return f'
{btns}
'
_ADR_MARKET_COLORS = {'KOSPI': '#fbbf24', 'KOSDAQ': '#34d399'}
_ADR_MARKET_LABELS = {'KOSPI': '코스피', 'KOSDAQ': '코스닥'}
def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
"""시장정보 sub-tab의 ADR 추세 카드. KOSPI/KOSDAQ 두 20일 이평선을 한 SVG 안에 합쳐 그린다.
저장 raw daily ADR은 stock.trade-journal launchd가 평일 21:00 누적, 오늘치 라이브는 _fetch_market_indicators(5분 SWR)에서.
차트는 각 시점의 trailing 20일 평균 — 누적 < 20일인 앞 구간은 평균 산출 불가라 점이 안 그려진다.
가이드선·날짜축은 양쪽 시장이 공유. 호버 시 한 세로선 + 시장당 점, 툴팁에 두 값 동시 표시."""
# 이평 산출엔 days 윈도우 밖의 과거 19일도 필요 → 전체 로드 후 trim.
history_all = _load_market_history(-1)
live_rows = _fetch_market_indicators() or []
live_by_market: dict[str, dict] = {}
for r in live_rows:
sym = r.get('symbol')
adr = r.get('adr')
bd = r.get('bizdate')
if not sym or adr is None:
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}
# 시장별 trailing 20일 이평 시리즈 — (date, ma, is_live).
# raw 시리즈(history+오늘 라이브)에서 시점별 trailing 평균 산출 후, days 파라미터로 마지막 N개 trim.
market_pts: dict[str, list[tuple[str, float, bool]]] = {}
raw_count_max = 0
for sym in ('KOSPI', 'KOSDAQ'):
raw_rows = history_all.get(sym, [])
live_pt = live_by_market.get(sym)
base = _build_adr_series_with_live(raw_rows, live_pt)
raw_count_max = max(raw_count_max, len(base))
ma_series = _compute_adr_ma_series(base)
if days >= 0 and len(ma_series) > days:
ma_series = ma_series[-days:]
market_pts[sym] = ma_series
summary_block = _render_adr_summary_block(live_rows)
# 두 시장 합쳐 점이 하나도 없으면 누적 부족 안내.
all_dates = sorted({d for pts in market_pts.values() for d, _, _ in pts if d})
if not all_dates:
sel_label = next((lbl for lbl, d in ADR_TREND_PRESETS if d == days), '맞춤')
hint = (f'{ADR_MA_WINDOW}일 누적 중 ({raw_count_max}/{ADR_MA_WINDOW}) — '
f'매 영업일 21:00 raw ADR 적재, {ADR_MA_WINDOW}일 채워지면 이평선 표시')
return (
'
'
)
date_to_i = {d: i for i, d in enumerate(all_dates)}
n_dates = len(all_dates)
# 차트 면적 — 두 라인 + 가이드 + 마커 라벨 여유. 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
PLOT_H = H - PAD_T - PAD_B
def x_for(i: int) -> float:
if n_dates == 1:
return PAD_L + PLOT_W / 2
return PAD_L + (PLOT_W * i / (n_dates - 1))
def y_for(v: float) -> float:
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''
f''
f''
f'100'
f'75'
f'125'
)
# 시장별 이평선 path + 마지막 마커. 이평은 매끄러운 단일 시리즈라 saved/live 분리 점선 불필요.
# 마지막 점이 오늘 라이브 raw를 포함한 평균이면 (is_live) open circle + 별표 마커.
line_parts: list[str] = []
marker_parts: list[str] = []
for sym in ('KOSPI', 'KOSDAQ'):
pts = market_pts[sym]
if not pts:
continue
color = _ADR_MARKET_COLORS[sym]
if len(pts) >= 2:
d_attr = ' L '.join(f'{x_for(date_to_i[d]):.1f} {y_for(v):.1f}' for d, v, _ in pts)
line_parts.append(f'')
last_d, last_v, last_is_live = pts[-1]
lx, ly = x_for(date_to_i[last_d]), y_for(last_v)
if last_is_live:
marker_parts.append(
f''
f'{last_v:.1f}*'
)
else:
marker_parts.append(
f''
f'{last_v:.1f}'
)
# 호버 인디케이터 — 세로선 1개 + 시장당 점 2개 (없는 시장은 JS가 hidden 처리).
hover_g = (
f''
f''
f''
f''
f''
)
# 포인트 메타 — 날짜축 i 기준. 각 날짜에 두 시장 값(있는 만큼) 포함.
# JS는 가장 가까운 x 찾고 양쪽 시장 점에 dot 배치 + 툴팁 두 줄 표시.
pts_meta: list[dict] = []
for i, d in enumerate(all_dates):
entry: dict = {'d': d, 'x': round(x_for(i), 2)}
for sym in ('KOSPI', 'KOSDAQ'):
found = next(((pv, plive) for pd, pv, plive in market_pts[sym] if pd == d), None)
if found is not None:
pv, plive = found
key = 'kospi' if sym == 'KOSPI' else 'kosdaq'
entry[key] = {'v': round(pv, 2), 'y': round(y_for(pv), 2), 'live': bool(plive)}
pts_meta.append(entry)
pts_json = html.escape(json.dumps(pts_meta, separators=(',', ':')), quote=True)
# 툴팁 — 날짜 + 두 시장 값 (각각 색상). 시장 값 비어있으면 JS가 해당 줄 hide.
tip_html = (
'
'
'
—
'
'
코스피—
'
'
코스닥—
'
'
'
)
# 시장 색 범례 — 기간 표시는 차트 하단 x축으로 이동(축 라벨로 더 적합).
legend_html = (
'
'
f'코스피'
f'코스닥'
'
'
)
# 차트 x축 하단 기간 라벨 — 시작/끝 날짜만 양 끝에 작게.
first_dt, last_dt = all_dates[0], all_dates[-1]
x_axis_y = H - 3
if first_dt == last_dt:
x_axis_label = (
f'{html.escape(first_dt[5:])}'
)
else:
x_axis_label = (
f'{html.escape(first_dt[5:])}'
f'{html.escape(last_dt[5:])}'
)
sel_label = next((lbl for lbl, d in ADR_TREND_PRESETS if d == days), '맞춤')
chart_row = (
f'
'
)
def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances: dict | None = None, journal_by_label: dict | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> str:
"""자산정보 탭 — sub-tab(자산보기/차트보기/시장정보)로 분리.
- 자산보기(기본): owner별 compact KPI 카드 스택 (합산 / 각계좌별도 토글)
- 차트보기: 기간·단위·모드 select + owner별 차트 (순자산 / 손익누적 토글)
- 시장정보: 오늘의 ADR·투자자별 매매 카드 + ADR 추세 sparkline (기간 select, 라이브 포인트)
추가 키움 호출 없음 (_fetch_all_data가 모은 owner_data 재사용). 시장 카드는 네이버 m.stock 별도 fetch (5분 SWR)."""
if not ordered_owners:
return '
계좌 데이터가 없습니다.
'
balances = balances or {}
journal_by_label = journal_by_label or {}
consolidated_parts: list[str] = []
by_account_parts: list[str] = []
chart_parts: list[str] = [_render_chart_controls(chart_days, chart_unit, chart_mode)]
for owner in ordered_owners:
d = owner_data.get(owner)
if not d:
continue
full_title = _owner_full_title(owner)
consolidated_parts.append(_render_owner_kpi(owner, d, full_title, compact=True))
# 각계좌별도: owner의 labels 순서로 단일 계좌 카드 누적. prev_net 유무로 day_pl 근사 토글.
has_prev_snap = d.get('prev_net') is not None
owner_short = full_title.replace('님 자산', '님').replace(' 자산', '')
owner_prefix = f'{owner}_'
for lb in d.get('labels', []):
label_disp = lb[len(owner_prefix):] if lb.startswith(owner_prefix) else lb
by_account_parts.append(_render_account_kpi(
owner, lb, d, balances, journal_by_label,
owner_label_text=owner_short,
has_prev_snap=has_prev_snap,
label_disp=label_disp,
))
chart_series, prev_baseline = _load_net_worth_series(owner, days=chart_days, unit=chart_unit, with_prev_baseline=True)
if chart_series:
# 스냅샷에 누적된 일자별 입출금 → unit 집계에 맞춰 sum.
daily_cf = _load_cash_flow_by_date(owner)
cf_by_date = _aggregate_cash_flows_by_unit(daily_cf, [d_ for d_, _ in chart_series], chart_unit) if daily_cf else {}
# 오늘 live 포인트용 — snapshot에 아직 안 들어간 오늘 입출금. owner_data에 이미 합산되어 있음.
today_cf = None
if d.get('cash_in', 0) or d.get('cash_out', 0):
today_cf = {'cash_in': d.get('cash_in', 0), 'cash_out': d.get('cash_out', 0)}
if chart_mode in ('pnl', 'period'):
# pnl·period 모두 '입출금 제거 누적손익' 시계열을 입력으로 받는다 (period는 _render에서 기간별 증분으로 그림).
# series[0] 직전 영업일 net_worth 를 baseline 으로 → 첫날 손익도 표시. fallback baseline 케이스(이번주 휴장, 1월 1일 등)는 prev_baseline=None.
prev_baseline_nw = prev_baseline[1] if prev_baseline else None
pnl_series, baseline_nw, cum_cf = _to_pnl_series(chart_series, cf_by_date, prev_baseline_nw=prev_baseline_nw)
# 차트 시각화에 baseline 점(예: 5/1) prepend → 첫 영업일 변동이 0 기점에서 분기되는 모양. baseline ↔ first 구간은 점선.
prev_baseline_point = prev_baseline if prev_baseline else None
# live current_pnl = current_net - baseline_nw - (시작점 이후 누적 cf + 오늘 cf).
# series 마지막 점이 오늘이면 today_cf 는 baseline 이후 누적에 안 들어있으니 추가로 빼줘야 일관.
current_net = d.get('total_net')
if current_net is None:
current_pnl = None
else:
today_net_cf = 0
if today_cf:
today_net_cf = int(today_cf.get('cash_in', 0) or 0) - int(today_cf.get('cash_out', 0) or 0)
current_pnl = int(current_net) - baseline_nw - cum_cf - today_net_cf
chart_parts.append(_render_net_worth_chart(
pnl_series,
current_net=current_pnl,
selected_days=chart_days,
selected_unit=chart_unit,
cash_flow_by_date=None, # pnl·period 모두 cf 영향 이미 제거 — 마커 숨김
today_cash_flow=None,
mode=chart_mode,
prev_baseline_point=prev_baseline,
))
else:
# net 모드도 baseline 점(예: 5/1) prepend → 첫 영업일 변동이 0 기점에서 분기되는 모양. baseline 구간은 점선.
chart_parts.append(_render_net_worth_chart(
chart_series,
current_net=d.get('total_net'),
selected_days=chart_days,
selected_unit=chart_unit,
cash_flow_by_date=cf_by_date,
today_cash_flow=today_cf,
mode='net',
prev_baseline_point=prev_baseline,
))
market_card = _render_market_indicators_card()
adr_trend_card = _render_adr_trend_card(days=adr_days)
# 자산보기 sub-panel 안 합산/각계좌별도 panes (양쪽 prerender). 토글 버튼은 페이지 상단(자동 토글 옆) 1곳으로 통일.
assets_inner = (
'
'
+ '\n'.join(consolidated_parts)
+ '
'
'
'
+ '\n'.join(by_account_parts)
+ '
'
)
return (
'
'
+ assets_inner
+ '
'
'
'
+ '\n'.join(chart_parts)
+ '
'
'
'
+ market_card
+ adr_trend_card
+ '
'
'
'
''
''
''
''
'
'
)
def _render_watchlist_panel(cards: list[dict]) -> tuple[str, int]:
if not cards:
return '
감시종목이 비어있습니다.
', 0
market_active = _market_active_now()
show_day_change = _show_day_change_now()
alerts_map = _load_alerts_map()
for c in cards:
c['_show_day_change'] = show_day_change
c['_alerted'] = alerts_map.get(c.get('stock') or '', [])
active = [c for c in cards if c.get('status') != 'pending_delete']
trash = [c for c in cards if c.get('status') == 'pending_delete']
held = sorted([c for c in active if c.get('mode') == 'held'],
key=lambda c: c.get('held_qty', 0) * (c.get('held_avg_price') or 0), reverse=True)
watching = sorted([c for c in active if c.get('mode') != 'held'],
key=lambda c: c.get('saved_at', ''), reverse=True)
trash.sort(key=lambda c: c.get('pending_delete_at', ''), reverse=True)
sections: list[str] = []
if held:
sections.append(f'
보유중{len(held)}
')
sections.append('\n'.join(_render_row(c) for c in held))
if watching:
sections.append(f'
매수전{len(watching)}
')
sections.append('\n'.join(_render_row(c) for c in watching))
if trash:
sections.append(f'
삭제예정{len(trash)}
')
sections.append('\n'.join(_render_row(c) for c in trash))
return '\n'.join(sections), len(cards)
def _render_interests_add_trigger() -> str:
"""패널 본문 하단에 표시되는 모달 오픈 트리거. 폼 자체는 shell HTML의 모달에 고정."""
return (
'
'
''
'
'
)
def _render_interests_edit_modal() -> str:
"""등록된 관심종목의 매수가/목표가/손절가/메모 수정 모달. shell HTML 직속에 둠.
삭제 버튼은 모달 내부에 별도 form(interest-delete-form 클래스)로 두고
HTML5 form attribute로 modal-actions의 submit 버튼에 연결 — 행 actions에서 빼냈다."""
return (
'
'
''
'
'
'
'
''
'
관심종목 수정 —
'
''
'
'
''
''
'
'
''
''
'
'
'
'
'
'
)
def _render_tag_modal() -> str:
"""종목 공용 태그 설정 모달. 어느 탭의 칩이든 +태그 버튼이든 동일하게 연다.
대장 버튼은 토글 — 클릭 즉시 set/clear & submit. 자유 입력은 텍스트칸 + 저장."""
return (
'
'
''
'
'
'
'
'
종목 태그 —
'
''
'
'
''
'
'
''
''
''
'
'
'
'
'
'
)
def _render_candidate_modal() -> str:
"""다중 매칭 시 사용자가 종목을 선택하는 별도 팝업. 추가 모달과 분리된 z-index 위층."""
return (
'
'
''
'
'
'
'
'
종목 선택
'
''
'
'
''
''
'
'
''
'
'
'
'
'
'
)
def _render_stock_name_modal() -> str:
"""거래내역 표에서 종목 셀 길게 누르면 풀네임 표시 — press-and-hold tooltip.
셀 위쪽으로 띄워 손가락에 가리지 않게. pointerdown=show / pointerup=hide."""
return (
''
)
def _render_trade_modal() -> str:
"""종목별 거래내역 팝업. shell HTML 직속이라 panels swap 영향 없음. trigger 클릭 → fetch → table 렌더."""
return (
'
'
)
def _render_info_desc_modal() -> str:
"""지수 설명 팝업. info-modal 위에 겹쳐 뜨는 modal-top. ? 버튼 클릭 시 용어·설명 표시."""
return (
'
'
''
'
'
'
'
'
지수 설명
'
''
'
'
''
'
'
''
'
'
'
'
'
'
)
def _render_order_modal() -> str:
"""주문 진입 모달 (매수/매도). 호가창 1초 폴링, PIN OTP 흐름.
1단계: 종목·계좌·수량·단가 입력 → /api/order/propose (PIN 텔레그램 발송)
2단계: PIN 입력 → /api/order/verify (실주문 실행)
3단계: 결과 표시
"""
return (
'
'
''
'
'
'
'
'
'
''
'
'
'
'
'
'
'—'
'—'
'—'
'
'
''
# 1단계: 입력
'
'
'
'
''
''
''
'
'
'
'
'
'
'
호가 로딩중…
'
'
'
'
'
''
''
''
''
''
'
—
'
'
'
''
''
'
'
'
'
'
'
'
'
'
'
'
'
)
def _render_open_orders_modal() -> str:
"""매매등록된 미체결 주문 목록 팝업. [📋 진행중] 탭이 표시 — 4계좌 통합 + 행별 취소.
각 행: 종목·계좌·방향·수량·단가·상태 + [취소] 버튼 → POST /api/orders/cancel.
"""
return (
'
'
''
'
'
'
'
'
📋 진행중 매매
'
'
'
'
'
'
불러오는 중…
'
'
'
'
'
''
''
'
'
'
'
'
'
)
def _render_pin_modal() -> str:
"""매매 카드 PIN 입력 팝업. order-modal의 propose 성공 직후 띄움.
카드 요약 + PIN 입력 칸 + 만료 카운트다운 + [카드 취소][주문 실행] 액션.
verify 결과는 같은 모달 안 결과 영역으로 swap.
"""
return (
'
'
''
'
'
'
'
'
주문 확인 — —
'
'
'
'
—
'
# PIN 입력
'
'
'
'
''
''
'
'
'
—
'
'
'
''
''
'
'
'
'
# 결과
'
'
'
—
'
'
'
''
'
'
'
'
'
'
'
'
)
def _render_interests_modal() -> str:
"""shell HTML 직속에 두는 종목 추가 모달. panels API의 swap 영역(section.tab-content) 밖이라
자동 갱신 중에도 DOM·입력값이 보존된다."""
return (
'
'
''
'
'
'
'
'
관심종목 추가
'
''
'
'
''
'
'
'
'
)
def _render_interests_panel(cards: list[dict]) -> tuple[str, int]:
sections: list[str] = []
if not cards:
sections.append('
관심종목이 비어있습니다.
')
else:
show_day_change = _show_day_change_now()
for c in cards:
c['_show_day_change'] = show_day_change
held = sorted([c for c in cards if c.get('mode') == 'held'],
key=lambda c: c.get('held_qty', 0) * (c.get('held_avg_price') or 0), reverse=True)
watching = sorted([c for c in cards if c.get('mode') != 'held'],
key=lambda c: c.get('saved_at', ''), reverse=True)
if held:
sections.append(f'
보유중{len(held)}
')
sections.append('\n'.join(_render_row(c, source='interests') for c in held))
if watching:
sections.append(f'
관심{len(watching)}
')
sections.append('\n'.join(_render_row(c, source='interests') for c in watching))
sections.append(_render_interests_add_trigger())
return '\n'.join(sections), len(cards)
# /api/panels?owner= 쿼리 값 → 내부 owner 키. None이면 전체.
TAB_KEY_TO_OWNER = {'self': '본인', 'gahee': '가희'}
def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> dict:
"""fetch_all_data + 탭별 패널 HTML 생성. /api/panels와 캐시의 단위.
owner 지정 시: 해당 owner 계좌만 호출하고 응답 tabs는 owner 탭 하나만 (summary·wl 제외).
클라이언트가 partial swap으로 사용 — 보지 않는 owner 호출 폭주 방지.
부수효과: fetched owner_data를 `_owner_data_cache`에 owner 단위로 적재해
이후 partial fetch가 'all' 캐시의 summary 슬라이스를 in-place 갱신할 때 재사용한다."""
wl_entries = _load_watchlist()
in_entries = _load_interests()
combined_entries = wl_entries + in_entries
data = _fetch_all_data(combined_entries, only_owner=owner)
all_cards = data['cards']
wl_cards = all_cards[:len(wl_entries)]
in_cards = all_cards[len(wl_entries):]
owner_data = data['owner_data']
ordered_owners = data['ordered_owners']
balances = data['balances']
journal_by_label = data.get('journal_by_label', {}) or {}
_save_owner_slices(owner_data, balances, journal_by_label)
now = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
fetched_hms = now # 셸 초기 렌더의 "갱신 YYYY-MM-DD HH:MM:SS" 포맷과 통일.
market_active = _market_active_now()
show_day_change = _show_day_change_now()
tabs: list[dict] = []
if owner is None:
# 전체 — 모든 탭 채움. 모두 동일 fetched_at.
wl_body, wl_count = _render_watchlist_panel(wl_cards)
in_body, in_count = _render_interests_panel(in_cards)
summary_body = _render_summary_panel(ordered_owners, owner_data, balances, journal_by_label, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days)
tabs.append({'id': 'tab-summary', 'label': '자산정보', 'html': summary_body, 'fetched_at': fetched_hms})
for o in ordered_owners:
d = owner_data[o]
tab_id = _owner_tab_id(o)
full_title = _owner_full_title(o)
title_short = full_title.replace('님 자산', '').replace(' 자산', '')
tab_label = title_short
panel = _render_owner_panel(o, d, balances, full_title, show_day_change, chart_days=chart_days, chart_unit=chart_unit)
tabs.append({'id': f'tab-{tab_id}', 'label': tab_label, 'html': panel, 'fetched_at': fetched_hms})
tabs.append({'id': 'tab-interests', 'label': f'관심종목 {in_count}', 'html': in_body, 'fetched_at': fetched_hms})
tabs.append({'id': 'tab-wl', 'label': f'감시종목 {wl_count}', 'html': wl_body, 'fetched_at': fetched_hms})
else:
# owner 단일 — 그 owner 탭만 응답. 클라이언트 apply가 해당 탭만 swap.
# 다른 탭들의 fetched_at은 클라이언트 측 tabFetched 누적 dict가 유지.
d = owner_data.get(owner)
if d:
tab_id = _owner_tab_id(owner)
full_title = _owner_full_title(owner)
title_short = full_title.replace('님 자산', '').replace(' 자산', '')
tab_label = title_short
panel = _render_owner_panel(owner, d, balances, full_title, show_day_change, chart_days=chart_days, chart_unit=chart_unit)
tabs.append({'id': f'tab-{tab_id}', 'label': tab_label, 'html': panel, 'fetched_at': fetched_hms})
indices = _fetch_indices()
return {
'now': now,
'market_active': market_active,
'first_tab_id': 'tab-summary',
'owner': owner,
'tabs': tabs,
'indices': indices,
'ticker_items': indices,
}
def _shell_tab_descriptors() -> list[tuple[str, str]]:
"""데이터 fetch 없이 (tab_id, placeholder_label) 순서. 셸 응답이 사용 — owner 라벨은 금액 없이 short form."""
descs: list[tuple[str, str]] = [('tab-summary', '자산정보')]
for owner, tid in OWNER_TAB_IDS.items():
full = _owner_full_title(owner)
short = full.replace('님 자산', '').replace(' 자산', '')
descs.append((f'tab-{tid}', short))
descs.append(('tab-interests', '관심종목'))
descs.append(('tab-wl', '감시종목'))
return descs
def render_html() -> str:
"""첫 진입용 셸 HTML — 데이터 fetch 없음, 즉시 응답.
클라이언트 JS가 /api/panels를 fetch해 본문/라벨을 DOM swap. 자동 갱신·PTR·새로고침 버튼도 같은 fetch 경로로 통일 (location.reload 없음)."""
descs = _shell_tab_descriptors()
valid_tids = [tid for tid, _ in descs]
first_tid = descs[0][0]
market_active = _market_active_now()
now = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
nav_html = '\n'.join(
f'{html.escape(label)}'
for tid, label in descs
)
spinner_placeholder = (
'
'
'불러오는 중…
'
)
content_html = '\n'.join(
f'{spinner_placeholder}'
for tid in valid_tids
)
show_rules = ',\n'.join(
f'html[data-tab="{tid}"] section[data-tab="{tid}"]' for tid in valid_tids
)
active_rules = ',\n'.join(
f'html[data-tab="{tid}"] nav.tabs a[href="#{tid}"]' for tid in valid_tids
)
dynamic_css = (
f'{show_rules},\n'
f'html:not([data-tab]) section[data-tab="{first_tid}"] {{ display: block; }}\n'
f'{active_rules},\n'
f'html:not([data-tab]) nav.tabs a[href="#{first_tid}"] '
f'{{ color: #f0f0f0; border-bottom-color: #ff4d5e; }}\n'
)
valid_tids_js = ','.join(f"'{tid}'" for tid in valid_tids)
# 자동 갱신 동작 탭 — 자산정보·본인·가희(owner 부분 fetch)·관심종목(전체 fetch). 감시종목은 시세 변화 추적 대상 아니라 제외.
auto_refresh_tids_js = ','.join(f"'{tid}'" for tid in valid_tids if tid != 'tab-wl')
# 탭 활성 동기화 — URL hash → html[data-tab]. CSS-only 전환이라 클릭은 즉시.
# 자산정보 탭으로 진입할 때는 전체 fetch 트리거 — 모든 owner를 한 화면에 모아 보는 요약 탭이라 stale 최소화.
head_script = (
''
)
# 패널 fetch + DOM swap.
# load() — 전체 (모든 탭 채움, 첫 진입·새로고침·PTR·자산정보 탭 진입)
# load("self"|"gahee") — owner 탭 하나만 받아 partial swap (자동 갱신)
# 응답 tabs는 받은 만큼만 swap하므로 보지 않는 owner의 stale 데이터는 그대로 둠.
# inFlight를 key별로 분리해 owner 갱신과 전체 fetch가 충돌하지 않도록.
panels_script = (
''
)
# 순자산 차트 인터랙션: 호버/터치 시 indicator 세로선 + 툴팁(그날 손익·누적 손익).
# data-pts JSON 파싱 → pointermove로 최근접 포인트 찾아 indicator·tooltip 갱신.
# MutationObserver로 swap 후 새 .net-chart 자동 재바인딩.
netchart_script = (
''
)
# ADR 통합 차트 인터랙션 — 터치/호버 시 세로선 + 시장당 점 + 듀얼 툴팁(날짜·코스피·코스닥).
# data-adr-pts JSON 각 entry: {d, x, kospi?:{v,y,live}, kosdaq?:{v,y,live}}.
# 영속화: hide 타이머 없음 + module 스코프 selectedDate 에 마지막 선택 날짜 저장 →
# 자동 갱신(panels innerHTML swap) 후 init 시 동일 날짜로 indicator 복원.
adr_hover_script = (
''
)
# 보유 카드 SVG 봉차트 — 카드 펼침 시 /api/chart_svg fetch.
# dailyCache: code -> 1Y/6M/1M HTML fragment.
# minuteCache: code:unit -> {svg, ts} (TTL 30s — 오늘 마지막 봉이 분 단위로 흐름).
# activeRange: code -> 마지막 활성 range. panels swap 후 1Y 로 reset 되는 거 방지.
# panels-loaded 이벤트마다 열린 카드 daily 복원 + activeRange 적용. 활성이 분봉이면 자동 재fetch.
chart_svg_script = (
''
)
# pull-to-refresh: 임계 넘으면 fetch(__behive_load) + spinner 유지, 완료 후 reset. reload() 안 함.
# iOS PWA엔 네이티브 PTR 부재라 직접 구현. resistance 0.55로 손맛.
ptr_script = (
''
)
# 자동 갱신 토글 — localStorage 영속, 계좌 탭 보일 때만 __behive_load 호출.
# 사이클: off(0) → 10s(1) → 3s(2) → off. 기존 "1" 값은 10s 모드와 호환.
# location.reload() 제거 → 깜빡임·탭 클릭 묻힘 없음. fetch 완료 후에 재무장(arm)해 호출 중첩 방지.
market_active_js = 'true' if market_active else 'false'
auto_reload_script = (
''
)
# 더블탭 zoom 차단 — iOS Safari가 `touch-action: manipulation`을 무시할 때 폴백.
# 300ms 안에 두 번째 touchend가 들어오면 preventDefault → 시스템 zoom 동작·동시 발생하는 ghost click 둘 다 억제.
# 첫 탭의 click은 정상 dispatch되므로 details 토글·새로고침 버튼 등 단일 탭 UX 영향 없음.
nodbltap_script = (
''
)
idx_info_modal_html = (
'
'
''
'
'
'
'
'
지수 설명
'
''
'
'
'
'
'
'
'—'
'—'
'
'
''
'
—
'
'
'
'
'
''
'
'
'
'
'
'
)
interest_modal_html = _render_interests_modal()
candidate_modal_html = _render_candidate_modal()
interest_edit_modal_html = _render_interests_edit_modal()
tag_modal_html = _render_tag_modal()
trade_modal_html = _render_trade_modal()
info_modal_html = _render_info_modal()
info_desc_modal_html = _render_info_desc_modal()
order_modal_html = _render_order_modal()
pin_modal_html = _render_pin_modal()
open_orders_modal_html = _render_open_orders_modal()
stock_name_modal_html = _render_stock_name_modal()
# 모달 열기/닫기 + 폼 fetch — 트리거 버튼은 panels swap으로 다시 그려지므로 이벤트 위임.
# 모달 DOM 자체는 shell HTML 직속이라 swap 영향 받지 않음 → 입력값 보존.
# /interests/add 응답은 Accept: application/json 시 JSON. 다중 매칭이면 후보 버튼 표시.
modal_script = (
''
)
# 거래내역 종목 셀 press-and-hold tooltip — pointerdown=show, pointerup/cancel/scroll=hide.
# 셀 상단에 띄워 손가락에 안 가리게. 화면 위 공간 부족하면 셀 하단으로 fallback.
stock_name_tip_script = (
''
)
# details.row[data-row-key] 그룹 mutex — 새로 열리면 다른 열려있던 것 자동 닫음.
# capture phase 사용: toggle 이벤트는 bubble 안 함. panels apply가 다시 그릴 때 setAttribute('open')
# 호출이 다시 trigger되어도 한 개만 남는다.
details_mutex_script = (
''
)
# 주문 모달 + PIN 모달 JS — 호가 1초 폴링, 매수/매도, 호가 클릭 → 단가, 계좌 select → /api/order/check.
# propose 성공 시 별도 PIN 모달(pin-modal)로 swap. verify는 PIN 모달 안에서 처리.
# window.openOrderModal({code, name, side?, account?, accounts?, price?}) — trigger 진입점.
order_modal_script = r''''''
return f'''
자산현황
{head_script}