Files
openclaw/agents/stock/workspace/scripts/behive_web.py
T
hyowons 549545bde6 Initial commit: OpenClaw 워크스페이스 버전관리 시작
설정·스크립트·스킬·문서·큐레이션 메모리 추적.
시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)·
백업(clobbered/bak)·dream 캐시는 .gitignore로 제외.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:10:57 +09:00

10125 lines
521 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""비하이브 워치리스트 실시간 웹 뷰. 페이지 열기·새로고침 시점에 키움 ka10095 batch 호출.
GET / → watchlist 카드형 HTML 한 페이지로 응답. JS·외부 리소스 없음 (인라인 CSS만).
GET /api/panels[?owner=self|gahee] → 패널 JSON (자동 갱신·partial swap용).
POST /{delete,restore,purge} → 감시종목 lifecycle (watchlist).
POST /interests/{add,update,delete} → 관심종목 수동 관리. JSON 응답 (Accept: application/json).
서버 바인드는 Tailscale IP(`100.75.148.12`)로 제한 — 외부망 직접 도달 차단.
외부 노출은 NAS reverse proxy(`stock.hyowons.net` → mac:18790) 경유.
write 엔드포인트는 인증·CSRF 가드 없음 — Tailnet 내부망 한정 운영을 전제.
CLI:
python3 behive_web.py serve # 서버 기동 (launchd가 호출)
python3 behive_web.py render [--out FILE] # HTML 한 번 렌더 후 출력 (디버깅용)
"""
from __future__ import annotations
import argparse
import gzip
import html
import json
import sys
import threading
import time
import traceback
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from zoneinfo import ZoneInfo
WORKSPACE = Path(__file__).resolve().parent.parent
WATCHLIST = WORKSPACE / 'state' / 'behive_watchlist.json'
INTERESTS = WORKSPACE / 'state' / 'behive_interests.json'
STOCK_TAGS = WORKSPACE / 'state' / 'behive_stock_tags.json'
ALERTS_STATE = WORKSPACE / 'state' / 'watchlist_alerts.json'
HOLIDAYS_FILE = WORKSPACE / 'state' / 'market_holidays.json'
SNAPSHOT_FILE = WORKSPACE / 'state' / 'portfolio_daily_snapshot.json'
TRADE_JOURNAL_FILE = WORKSPACE / 'state' / 'trade_journal.jsonl'
MARKET_HISTORY_FILE = WORKSPACE / 'state' / 'market_indicators_history.jsonl'
ADR_TREND_DAYS = 30 # 시장정보 sub-tab ADR 추세 차트 default 기간 (영업일 기준 최근 N행)
# (label, days) — days=-1 전체. UI 버튼·서버 sanity check·localStorage 허용값 모두 단일 진실 출처.
ADR_TREND_PRESETS = [('1주', 7), ('1달', 30), ('3달', 90), ('6달', 180), ('1년', 365), ('전체', -1)]
ADR_MA_WINDOW = 20 # ADR 이동평균 윈도우. 카드 수치·차트 모두 이 값 사용. 가용 데이터 < window 면 평균 산출 안 함
NET_WORTH_CHART_DAYS = -2 # default = 이번달. 클라이언트가 ?chart_days로 override
# (label, days) — 클라이언트 UI 옵션. days=-1 전체, -2 이번달(KST 기준), -3 이번주(이번 ISO 월요일~오늘).
NET_WORTH_CHART_PRESETS = [('전체보기', -1), ('올해', -4), ('이번달', -2), ('이번주', -3)]
# 차트 단위 토글 — 각 구간 마지막 영업일 net_worth만 표시. 'day'=기본 일별.
NET_WORTH_UNIT_PRESETS = [('일별', 'day'), ('주별', 'week'), ('월별', 'month'), ('연별', 'year')]
NET_WORTH_CHART_UNIT = 'day'
# 단위 → tooltip 손익 라벨 매핑
_NET_WORTH_DELTA_LABEL = {'day': '그날 손익', 'week': '주간 손익', 'month': '월간 손익', 'year': '연간 손익'}
# 기간별 허용 단위 — 의미없는 조합(이번주+월별 등) 비활성용. 기간이 단위 너비보다 좁으면 비허용.
NET_WORTH_ALLOWED_UNITS_BY_DAYS: dict[int, tuple[str, ...]] = {
-3: ('day',), # 이번주 — day 만 (주별은 한 점이라 의미 없음)
-2: ('day', 'week'), # 이번달 — month 빼고 (월별은 한 점이라 의미 없음)
-4: ('day', 'week', 'month'), # 올해 — year 빼고 (연별은 한 점이라 의미 없음)
-1: ('day', 'week', 'month', 'year'), # 전체 — 모두
}
# 차트 표시 모드 — 'pnl'=시작점 baseline=0 누적손익(기본), 'net'=순자산 시계열.
# 'pnl'=시작점 baseline=0 누적손익(전부 누적), 'period'=각 점이 그 기간(일/주/월/연) 자체 손익, 'net'=순자산 시계열.
NET_WORTH_MODE_PRESETS = [('순자산', 'net'), ('손익누적', 'pnl'), ('기간손익', 'period')]
NET_WORTH_CHART_MODE = 'pnl'
KST = ZoneInfo('Asia/Seoul')
BIND_HOST = '' # 모든 인터페이스 (Tailscale + LAN). 외부망 직접 차단은 공유기 NAT가 담당.
BIND_PORT = 18790
QUOTE_WORKERS = 8
QUOTE_CACHE_TTL = 30.0 # 첫 cold 8s 동안 분산 박힌 캐시가 다음 RENDER 만료(10s) 시점에 살아있도록. 가격 stale 최대 30s.
sys.path.insert(0, str(WORKSPACE / 'scripts'))
_holidays_cache: dict = {'mtime': 0.0, 'set': set()}
_traded_codes_cache: dict = {'mtime': 0.0, 'set': set()}
_quote_cache: dict[str, tuple[dict, float]] = {}
_quote_cache_lock = threading.Lock()
INDICES_CACHE_TTL = 30.0
_indices_cache: dict = {'data': None, 'expires_at': 0.0}
_indices_lock = threading.Lock()
_indices_refresh_in_flight = False
# ticker 회전 순서: 국내 → 미국 → 환율/원자재. ex.map으로 입력 순서 보존.
_INDEX_SOURCES = (
('naver_kr', 'KOSPI', 'KOSPI', None),
('naver_kr', 'KOSDAQ', 'KOSDAQ', None),
('naver_gl', '.INX', 'S&P500', None),
('naver_gl', '.IXIC', 'NASDAQ', None),
('naver_gl', '.N225', '닛케이225', None),
('yahoo', 'KRW=X', 'USD/KRW', 2),
('yahoo', 'CL=F', 'WTI', 2),
('yahoo', 'GC=F', '', 2),
('yahoo', 'SI=F', '', 2),
('yahoo', 'HG=F', '', 3),
)
def _fmt_quote(label: str, price: float, change: float, pct: float, decimals: int = 2) -> dict:
direction = 'up' if change > 0 else ('down' if change < 0 else 'flat')
return {
'kind': 'index',
'label': label,
'price': f'{price:,.{decimals}f}',
'change': f'{abs(change):,.{decimals}f}',
'pct': f'{abs(pct):.2f}',
'direction': direction,
}
def _fetch_naver_index(code: str, label: str, base: str) -> dict | None:
"""네이버 stock API로 지수 1개 fetch. base는 국내(`m.stock.naver.com/api`) 또는 해외(`api.stock.naver.com`)."""
import urllib.request
try:
url = f'{base}/index/{code}/basic'
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=3.0) as resp:
raw = resp.read()
data = json.loads(raw.decode('utf-8', 'ignore'))
price_s = str(data.get('closePrice') or '').replace(',', '')
change_s = str(data.get('compareToPreviousClosePrice') or '').replace(',', '')
pct_s = str(data.get('fluctuationsRatio') or '').replace(',', '')
if not price_s:
return None
return _fmt_quote(label, float(price_s), float(change_s or 0), float(pct_s or 0), 2)
except Exception as e:
sys.stderr.write(f'naver index fetch {code} failed: {e}\n')
return None
def _fetch_yahoo_quote(symbol: str, label: str, decimals: int = 2) -> dict | None:
"""Yahoo Finance chart API로 종목·환율·원자재 1개 fetch. 마지막 두 close 비교로 등락."""
import urllib.request
try:
url = f'https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range=2d&interval=1d'
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=3.0) as resp:
raw = resp.read()
data = json.loads(raw.decode('utf-8', 'ignore'))
closes = [x for x in data['chart']['result'][0]['indicators']['quote'][0]['close'] if x is not None]
if not closes:
return None
cur = closes[-1]
prev = closes[-2] if len(closes) >= 2 else cur
change = cur - prev
pct = (change / prev * 100) if prev else 0.0
return _fmt_quote(label, cur, change, pct, decimals)
except Exception as e:
sys.stderr.write(f'yahoo fetch {symbol} failed: {e}\n')
return None
def _fetch_one_index(spec) -> dict | None:
kind, code, label, decimals = spec
if kind == 'naver_kr':
return _fetch_naver_index(code, label, 'https://m.stock.naver.com/api')
if kind == 'naver_gl':
return _fetch_naver_index(code, label, 'https://api.stock.naver.com')
if kind == 'yahoo':
return _fetch_yahoo_quote(code, label, decimals)
return None
def _fetch_indices_now() -> list[dict]:
"""10개 source 병렬 fetch (ex.map으로 순서 보존). 각 source는 3s timeout."""
with ThreadPoolExecutor(max_workers=len(_INDEX_SOURCES)) as ex:
results = list(ex.map(_fetch_one_index, _INDEX_SOURCES))
return [r for r in results if r]
def _refresh_indices_async() -> None:
"""SWR 백그라운드 갱신. single-flight 가드로 다중 fetch 차단."""
global _indices_refresh_in_flight
with _indices_lock:
if _indices_refresh_in_flight:
return
_indices_refresh_in_flight = True
def _run():
global _indices_refresh_in_flight
try:
data = _fetch_indices_now()
if data:
with _indices_lock:
_indices_cache['data'] = data
_indices_cache['expires_at'] = time.time() + INDICES_CACHE_TTL
except Exception as e:
sys.stderr.write(f'indices background refresh failed: {e}\n')
finally:
with _indices_lock:
_indices_refresh_in_flight = False
threading.Thread(target=_run, daemon=True, name='indices-swr').start()
def _fetch_indices() -> list[dict]:
"""ticker 회전용 시장 지표 — KOSPI/KOSDAQ + 미국 3대 지수 + USD/KRW + WTI/금.
TTL 30s + stale-while-revalidate: 캐시 있으면 즉시 반환(만료여도 stale 반환 + 백그라운드 갱신).
캐시 비었을 때만 동기 병렬 fetch. 첫 cold ~300ms, 이후 사용자는 indices 대기 없음."""
now = time.time()
with _indices_lock:
cached = _indices_cache['data']
fresh = cached is not None and _indices_cache['expires_at'] > now
if cached:
if not fresh:
_refresh_indices_async()
return cached
data = _fetch_indices_now()
if data:
with _indices_lock:
_indices_cache['data'] = data
_indices_cache['expires_at'] = now + INDICES_CACHE_TTL
return data
return []
# 시장 상황 카드 — ADR(상승/하락 종목수)과 투자자별 매매(개인/외국인/기관) 데이터.
# 네이버 m.stock 비공식 API 한 콜로 KOSPI/KOSDAQ 둘 다 fetch.
# 분당 변화 폭이 작아 5분 TTL + stale-while-revalidate.
MARKET_INDICATORS_TTL = 300.0
_market_indicators_cache: dict = {'data': None, 'expires_at': 0.0}
_market_indicators_lock = threading.Lock()
_market_indicators_refresh_in_flight = False
_MARKET_INDICATOR_SYMBOLS = (('KOSPI', '코스피'), ('KOSDAQ', '코스닥'))
def _parse_signed_int(s) -> int | None:
"""네이버 응답은 '+13,930' / '-25,461' / '0' 형태. 콤마·부호 제거 후 정수."""
if s is None:
return None
try:
return int(str(s).replace(',', '').replace('+', '').strip())
except (ValueError, TypeError):
return None
def _parse_count(s) -> int:
"""upDownStockInfo 값은 '4' / '1,450' / '839' — 콤마 포함 가능. 실패 시 0."""
if s is None:
return 0
try:
return int(str(s).replace(',', '').strip())
except (ValueError, TypeError):
return 0
def _fetch_one_market_indicator(symbol_label: tuple[str, str]) -> dict | None:
"""네이버 m.stock integration API 한 콜로 시장 1개의 dealTrendInfo + upDownStockInfo 수집."""
import urllib.request
symbol, label = symbol_label
try:
url = f'https://m.stock.naver.com/api/index/{symbol}/integration'
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=3.0) as resp:
raw = resp.read()
data = json.loads(raw.decode('utf-8', 'ignore'))
except Exception as e:
sys.stderr.write(f'market indicator fetch {symbol} failed: {e}\n')
return None
up_down = data.get('upDownStockInfo') or {}
rise = _parse_count(up_down.get('riseCount'))
upper = _parse_count(up_down.get('upperCount'))
fall = _parse_count(up_down.get('fallCount'))
lower = _parse_count(up_down.get('lowerCount'))
steady = _parse_count(up_down.get('steadyCount'))
adv = rise + upper
dec = fall + lower
adr = (adv / dec * 100.0) if dec else None
deal = data.get('dealTrendInfo') or {}
personal = _parse_signed_int(deal.get('personalValue'))
foreign = _parse_signed_int(deal.get('foreignValue'))
institutional = _parse_signed_int(deal.get('institutionalValue'))
return {
'symbol': symbol,
'label': label,
'rise': rise,
'upper': upper,
'fall': fall,
'lower': lower,
'steady': steady,
'adr': adr,
'bizdate': str(deal.get('bizdate') or '').strip() or None,
'personal': personal,
'foreign': foreign,
'institutional': institutional,
}
def _fetch_market_indicators_now() -> list[dict]:
with ThreadPoolExecutor(max_workers=len(_MARKET_INDICATOR_SYMBOLS)) as ex:
results = list(ex.map(_fetch_one_market_indicator, _MARKET_INDICATOR_SYMBOLS))
return [r for r in results if r]
# 분기 영업이익 캐시 — (value_eok, period, fetched_at). 분기 데이터는 자주 안 변해 6h TTL.
_QUARTER_OP_CACHE: dict[str, tuple] = {}
_QUARTER_OP_TTL = 6 * 3600
def _fetch_quarter_op_profit(code: str) -> dict | None:
"""네이버 m.stock 분기 재무에서 '최근 확정 분기' 영업이익(억원) + 분기 라벨 반환.
네이버는 응답에 미래 분기 컨센서스(증권사 추정)를 isConsensus='Y'로 섞어 준다.
그 추정치만 제외하고, 확정(N) 분기 중 가장 최근 것을 채택한다.
(값 자체에 대한 임계 가드는 두지 않는다 — 네이버의 확정 분류를 신뢰. 호황기 급증
실적을 오탐 차단하던 과거 가드를 제거함.)
반환: {'value': 억원(int), 'period': 'YYYY.MM'} 또는 None.
"""
import time as _t
now = _t.time()
hit = _QUARTER_OP_CACHE.get(code)
if hit and (now - hit[2]) < _QUARTER_OP_TTL:
return {'value': hit[0], 'period': hit[1]} if hit[0] is not None else None
import urllib.request
try:
url = f'https://m.stock.naver.com/api/stock/{code}/finance/quarter'
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=3.0) as resp:
data = json.loads(resp.read().decode('utf-8', 'ignore'))
fi = data.get('financeInfo') or {}
titles = fi.get('trTitleList') or []
op_cols = {}
for r in (fi.get('rowList') or []):
if r.get('title') == '영업이익':
op_cols = r.get('columns') or {}
break
# 컨센서스(추정) 제외, 확정 분기 key 내림차순(최근 우선)
cand = [t for t in titles if t.get('isConsensus') != 'Y' and t.get('key')]
cand.sort(key=lambda t: t['key'], reverse=True)
chosen = None
for t in cand:
raw = (op_cols.get(t['key']) or {}).get('value')
if raw in (None, '', '-'):
continue
try:
val = int(str(raw).replace(',', ''))
except ValueError:
continue
chosen = {'value': val, 'period': (t.get('title') or '').rstrip('.')}
break
_QUARTER_OP_CACHE[code] = (chosen['value'] if chosen else None,
chosen['period'] if chosen else None, now)
return chosen
except Exception as e:
sys.stderr.write(f'quarter op_profit fetch {code} failed: {e}\n')
return None
def _refresh_market_indicators_async() -> None:
global _market_indicators_refresh_in_flight
with _market_indicators_lock:
if _market_indicators_refresh_in_flight:
return
_market_indicators_refresh_in_flight = True
def _run():
global _market_indicators_refresh_in_flight
try:
data = _fetch_market_indicators_now()
if data:
with _market_indicators_lock:
_market_indicators_cache['data'] = data
_market_indicators_cache['expires_at'] = time.time() + MARKET_INDICATORS_TTL
except Exception as e:
sys.stderr.write(f'market indicators background refresh failed: {e}\n')
finally:
with _market_indicators_lock:
_market_indicators_refresh_in_flight = False
threading.Thread(target=_run, daemon=True, name='market-indicators-swr').start()
def _fetch_market_indicators() -> list[dict]:
"""KOSPI/KOSDAQ의 ADR·투자자별 매매. 5분 TTL + stale-while-revalidate.
실패시 빈 리스트 — caller(_render_market_panel)가 graceful degrade 처리."""
now = time.time()
with _market_indicators_lock:
cached = _market_indicators_cache['data']
fresh = cached is not None and _market_indicators_cache['expires_at'] > now
if cached:
if not fresh:
_refresh_market_indicators_async()
return cached
data = _fetch_market_indicators_now()
if data:
with _market_indicators_lock:
_market_indicators_cache['data'] = data
_market_indicators_cache['expires_at'] = now + MARKET_INDICATORS_TTL
return data
return []
def _load_holidays() -> set[str]:
"""state/market_holidays.json에서 KRX 휴장일 set(YYYY-MM-DD) 로드. 파일 없거나 깨지면 빈 set.
파일 mtime 기반 lazy reload — holiday_sync가 갱신하면 다음 호출에서 자동 반영."""
if not HOLIDAYS_FILE.exists():
return set()
try:
mtime = HOLIDAYS_FILE.stat().st_mtime
except OSError:
return _holidays_cache.get('set', set())
if mtime != _holidays_cache.get('mtime'):
try:
data = json.loads(HOLIDAYS_FILE.read_text())
_holidays_cache['set'] = set((data.get('holidays') or {}).keys())
_holidays_cache['mtime'] = mtime
except Exception as e:
sys.stderr.write(f'holidays load failed: {e}\n')
return _holidays_cache.get('set', set())
def _load_traded_codes() -> set[str]:
"""trade_journal.jsonl 에서 distinct 종목코드 set 로드. mtime 기반 lazy reload.
워치리스트·관심종목 행에서 '거래내역' 버튼 노출 여부 판정용."""
if not TRADE_JOURNAL_FILE.exists():
return set()
try:
mtime = TRADE_JOURNAL_FILE.stat().st_mtime
except OSError:
return _traded_codes_cache.get('set', set())
if mtime != _traded_codes_cache.get('mtime'):
codes: set[str] = set()
try:
for ln in TRADE_JOURNAL_FILE.read_text().splitlines():
ln = ln.strip()
if not ln:
continue
try:
d = json.loads(ln)
c = d.get('code')
if c:
codes.add(c)
except json.JSONDecodeError:
continue
_traded_codes_cache['set'] = codes
_traded_codes_cache['mtime'] = mtime
except Exception as e:
sys.stderr.write(f'trade codes load failed: {e}\n')
return _traded_codes_cache.get('set', set())
_US_EASTERN = ZoneInfo('America/New_York')
def _us_market_open() -> bool:
"""미국 야간/새벽 sentiment 블록 표시 기간 — KST 22:30 ~ 다음날 10:00.
미국 정규장(ET 09:30~16:00 = KST 22:30~05:00 DST) 시간 + 미국장 마감 후 한국 정규장 개장 1시간까지.
한국 정규장 시작 후 10:00 이전엔 야간 미국 마감 데이터가 여전히 sentiment 참조용으로 유효."""
now_kst = datetime.now(KST)
hm = now_kst.hour * 60 + now_kst.minute
return hm >= 22 * 60 + 30 or hm < 10 * 60
# 미국 장 시간에 시장 카드 KOSPI/KOSDAQ 자리를 채울 KOSPI 200 + MSCI Korea(EWY) quote 캐시.
# 5분 TTL. _fetch_yahoo_quote 재사용. 한국 정규장 시간엔 fetch X (캐시도 만료 → 빈 결과).
_EXTRA_MARKET_QUOTES_TTL = 300.0
_extra_market_quotes_cache: dict = {'data': None, 'expires_at': 0.0}
_extra_market_quotes_lock = threading.Lock()
# 지수 차트 sparkline 캐시 — 모달의 lazy fetch. 5분 TTL. label → Yahoo symbol 매핑은 _IDX_CHART_SYMBOL.
_IDX_CHART_SYMBOL = {
'KOSPI': '^KS11',
'KOSDAQ': '^KQ11',
'S&P500': '^GSPC',
'필라델피아 반도체': '^SOX',
'MSCI Korea (EWY)': 'EWY',
'VIX (공포지수)': '^VIX',
'미국채 10년 (%)': '^TNX',
}
_IDX_CHART_TTL = 300.0
_idx_chart_cache: dict = {} # symbol -> {'data': list[float], 'expires_at': float}
_idx_chart_lock = threading.Lock()
def _fetch_idx_chart_series(symbol: str) -> list[dict] | None:
"""Yahoo chart API 로 최근 1개월 일별 close 시리즈. 5분 TTL per-symbol. 각 점은 {'d': iso_date, 'v': close}."""
now = time.time()
with _idx_chart_lock:
c = _idx_chart_cache.get(symbol)
if c and c.get('expires_at', 0) > now:
return c['data']
import urllib.request
try:
url = f'https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range=1mo&interval=1d'
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=4.0) as resp:
data = json.loads(resp.read().decode('utf-8', 'ignore'))
result = data.get('chart', {}).get('result') or []
if not result:
return None
timestamps = result[0].get('timestamp') or []
closes = result[0]['indicators']['quote'][0].get('close') or []
series: list[dict] = []
for ts, c in zip(timestamps, closes):
if c is None:
continue
try:
d_iso = datetime.fromtimestamp(int(ts), tz=KST).strftime('%Y-%m-%d')
except Exception:
continue
series.append({'d': d_iso, 'v': float(c)})
if len(series) < 2:
return None
with _idx_chart_lock:
_idx_chart_cache[symbol] = {'data': series, 'expires_at': now + _IDX_CHART_TTL}
return series
except Exception as e:
sys.stderr.write(f'idx chart fetch {symbol} failed: {e}\n')
return None
def _fetch_extra_market_quotes() -> list[dict] | None:
"""미국 장 시간 전용: KOSPI 200 + MSCI Korea(EWY) 두 quote. 5분 캐시.
fetch 실패하거나 부분 실패면 빈 quote 자리 None 으로. 모두 실패면 None 리턴."""
now = time.time()
with _extra_market_quotes_lock:
cached = _extra_market_quotes_cache['data']
fresh = cached is not None and _extra_market_quotes_cache['expires_at'] > now
if cached and fresh:
return cached
results = []
for sym, lbl, dec in (
('EWY', 'MSCI Korea (EWY)', 2),
('^SOX', '필라델피아 반도체', 2),
('^VIX', 'VIX (공포지수)', 2),
('^TNX', '미국채 10년 (%)', 3),
):
q = _fetch_yahoo_quote(sym, lbl, dec)
results.append(q)
if not any(results):
return None
with _extra_market_quotes_lock:
_extra_market_quotes_cache['data'] = results
_extra_market_quotes_cache['expires_at'] = now + _EXTRA_MARKET_QUOTES_TTL
return results
def _market_active_now() -> bool:
"""NXT 운영 시간대(평일 08:00~20:00 KST) 안 + KRX 휴장일 아님.
이 조건 밖에선 자동 갱신 토글이 비활성화된다.
휴장일 데이터는 holiday_sync가 주 1회 investing.com에서 갱신."""
now = datetime.now(KST)
if now.weekday() >= 5: # 토(5)·일(6)
return False
if now.hour < 8 or now.hour >= 20:
return False
if now.strftime('%Y-%m-%d') in _load_holidays():
return False
return True
def _show_day_change_now() -> bool:
"""종목 등락률 표시 시간대 — 평일 08:00 ~ 다음날 06:00 (KST).
NXT 마감(20:00) 이후에도 다음날 새벽 06:00까지 직전 거래일 등락률을 계속 표시.
주말·휴장일은 표시 X (단, 직전 거래일이 평일 거래일이면 그 새벽 시간대까지는 ON)."""
now = datetime.now(KST)
today_str = now.strftime('%Y-%m-%d')
holidays = _load_holidays()
wd = now.weekday()
hour = now.hour
# 평일 08:00 이후 — 오늘 거래일 진행 (휴장 아닐 때)
if wd < 5 and hour >= 8 and today_str not in holidays:
return True
# 평일 또는 토요일 00:00~06:00 — 직전 거래일이 평일·비휴장이면 그 결과 표시
if hour < 6 and wd <= 5:
prev = now - timedelta(days=1)
if prev.weekday() < 5 and prev.strftime('%Y-%m-%d') not in holidays:
return True
return False
def _market_phase_state() -> dict:
"""현재 시각 기준 시장 phase 판정. 거래 모달 시간 가드용.
- regular(정규장): 평일 09:00~15:30 — KRX+NXT 거래 가능
- nxt(NXT): 평일 08:00~09:00 + 15:30~20:00 — NXT만 거래 가능
- closed(장외): 평일 그 외 — 매매 불가
- holiday(휴장): KRX 휴장일 — 매매 불가
- weekend(주말): 토·일 — 매매 불가
"""
now = datetime.now(KST)
time_str = now.strftime('%H:%M')
if now.weekday() >= 5:
return {'phase': 'weekend', 'label': '주말 휴장', 'active': False, 'time': time_str}
if now.strftime('%Y-%m-%d') in _load_holidays():
return {'phase': 'holiday', 'label': '휴장일', 'active': False, 'time': time_str}
hm = now.hour * 60 + now.minute
if 9 * 60 <= hm < 15 * 60 + 30:
return {'phase': 'regular', 'label': '정규장', 'active': True, 'time': time_str}
if 8 * 60 <= hm < 20 * 60:
return {'phase': 'nxt', 'label': 'NXT 시장', 'active': True, 'time': time_str}
return {'phase': 'closed', 'label': '장외 시간', 'active': False, 'time': time_str}
def _load_watchlist() -> list[dict]:
if not WATCHLIST.exists():
return []
data = json.loads(WATCHLIST.read_text())
return sorted(data.values(), key=lambda e: e.get('saved_at', ''), reverse=True)
def _load_interests() -> list[dict]:
if not INTERESTS.exists():
return []
data = json.loads(INTERESTS.read_text())
return sorted(data.values(), key=lambda e: e.get('saved_at', ''), reverse=True)
def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
"""ka10095(관심종목정보) 한 콜로 워치리스트 시세 일괄 조회. 19종목 ~0.05s.
캐시 히트인 entry는 캐시 사용, 미스인 entry만 batch에 포함.
batch 실패·응답 누락 종목은 _fetch_quote 단건(ka10001) fallback."""
import kiwoom_client as kc
now = time.time()
cards: list[dict] = []
miss_codes: list[str] = []
miss_indices: list[int] = []
for e in entries:
out = dict(e)
code = (e.get('code') or '').strip()
name = e.get('stock', '')
token = code or name
out['quote_error'] = None
out['price'] = None
out['day_change'] = None
out['day_change_pct'] = None
out['open'] = None
out['high'] = None
out['low'] = None
cards.append(out)
if not token:
out['quote_error'] = '종목 식별 실패'
continue
with _quote_cache_lock:
cached = _quote_cache.get(token)
if cached and cached[1] > now:
snap = cached[0]
out['price'] = snap['price']
out['day_change'] = snap['day_change']
out['day_change_pct'] = snap['day_change_pct']
out['quote_error'] = snap['quote_error']
out['open'] = snap.get('open')
out['high'] = snap.get('high')
out['low'] = snap.get('low')
out['_krx_price'] = snap.get('_krx_price', 0)
out['_nxt_price'] = snap.get('_nxt_price', 0)
continue
if code:
miss_codes.append(code)
miss_indices.append(len(cards) - 1)
else:
# 코드 없는 entry는 resolve 거쳐야 해서 단건 호출 — 거의 없음.
cards[-1] = _fetch_quote(e)
if not miss_codes:
return cards
try:
batch = kc.get_watchlist_quotes(miss_codes)
except Exception as ex:
sys.stderr.write(f'ka10095 batch 실패, ka10001 fallback: {ex}\n')
for idx in miss_indices:
cards[idx] = _fetch_quote(entries[idx])
return cards
# NXT 비교용 batch — 마크(KRX/NXT) 판정에 사용. 실패해도 fallback X (마크만 KRX로 처리).
nxt_batch: dict[str, dict] = {}
try:
nxt_batch = kc.get_watchlist_quotes(miss_codes, exchange='NXT')
except Exception as ex:
sys.stderr.write(f'ka10095 NXT batch 실패 (마크 판정용): {ex}\n')
for idx, code in zip(miss_indices, miss_codes):
q = batch.get(code)
out = cards[idx]
if not q:
cards[idx] = _fetch_quote(entries[idx])
continue
price = q.get('price') or 0
if price:
out['price'] = price
out['day_change'] = q.get('change')
out['day_change_pct'] = q.get('change_pct')
out['open'] = q.get('open') or None
out['high'] = q.get('high') or None
out['low'] = q.get('low') or None
else:
out['quote_error'] = '가격 0'
# 마크 판정: NXT batch 가격 vs 기본 batch 가격 비교.
nxt_q = nxt_batch.get(code) if nxt_batch else None
nxt_price = (nxt_q or {}).get('price', 0) if nxt_q else 0
out['_krx_price'] = price
out['_nxt_price'] = nxt_price
snap = {
'price': out['price'],
'day_change': out['day_change'],
'day_change_pct': out['day_change_pct'],
'quote_error': out['quote_error'],
'open': out['open'],
'high': out['high'],
'low': out['low'],
'_krx_price': price,
'_nxt_price': nxt_price,
}
with _quote_cache_lock:
_quote_cache[code] = (snap, time.time() + QUOTE_CACHE_TTL)
return cards
def _fetch_quote(entry: dict) -> dict:
"""키움 ka10001 — 단일 종목 현재가 fallback. 평소 _fetch_quotes_batch가 처리.
종목별 QUOTE_CACHE_TTL 캐시로 batch 실패 시에도 rate limit 완화."""
import kiwoom_client as kc
out = dict(entry)
code = (entry.get('code') or '').strip()
name = entry.get('stock', '')
target_token = code or name
out['quote_error'] = None
out['price'] = None
out['day_change'] = None
out['day_change_pct'] = None
out['open'] = None
out['high'] = None
out['low'] = None
if not target_token:
out['quote_error'] = '종목 식별 실패'
return out
now = time.time()
with _quote_cache_lock:
cached = _quote_cache.get(target_token)
if cached and cached[1] > now:
snap = cached[0]
out['price'] = snap['price']
out['day_change'] = snap['day_change']
out['day_change_pct'] = snap['day_change_pct']
out['quote_error'] = snap['quote_error']
out['open'] = snap.get('open')
out['high'] = snap.get('high')
out['low'] = snap.get('low')
return out
try:
info = kc.resolve_stock_code(target_token) if not code else {'code': code}
q = kc.get_stock_quote(info['code'])
price = q.get('price') or 0
if not price:
out['quote_error'] = '가격 0'
else:
out['price'] = price
out['day_change'] = q.get('change')
out['day_change_pct'] = q.get('change_pct')
except Exception as e:
out['quote_error'] = str(e)[:80]
snap = {
'price': out['price'],
'day_change': out['day_change'],
'day_change_pct': out['day_change_pct'],
'quote_error': out['quote_error'],
'open': out['open'],
'high': out['high'],
'low': out['low'],
}
with _quote_cache_lock:
_quote_cache[target_token] = (snap, time.time() + QUOTE_CACHE_TTL)
return out
def _holdings_map_from_positions(positions_by_acc: dict) -> dict:
"""positions_by_acc → 종목코드 keyed dict (워치리스트 카드 annotate용)."""
agg: dict = {}
for label, positions in positions_by_acc.items():
for p in positions:
code = p.get('code') or ''
if not code:
continue
cur = agg.get(code)
if cur:
new_qty = cur['qty'] + p['qty']
new_pur = cur['pur_amt'] + p['pur_amt']
cur['qty'] = new_qty
cur['pur_amt'] = new_pur
cur['avg_price'] = new_pur // new_qty if new_qty else 0
cur['accounts'].append(label)
else:
agg[code] = {
'qty': p['qty'],
'pur_amt': p['pur_amt'],
'avg_price': p['avg_price'],
'name': p['name'],
'accounts': [label],
}
return agg
def _build_owner_data(positions_by_acc: dict, balances: dict, journal_by_label: dict, prev_snap_all: dict | None = None, cash_flow_by_label: dict | None = None) -> tuple[list[str], dict]:
"""계좌 데이터 → owner 그룹별 집계. stock_portfolio_report와 동일 로직.
`당일 평가손익`은 prev_snap_all(전날 stock.briefing 스냅샷) 기반 `total_net - prev_net - net_cash_flow`.
이 차이값은 보유분의 미실현 등락 + 오늘 매매의 실현손익 + 매수/매도 현금흐름까지 자연스럽게 포함하되,
외부 입출금(cash flow)은 손익이 아니므로 net_cash_flow로 분리해 제거한다.
스냅샷이 없으면 None — KPI에 '전날 스냅샷 없음'으로 표시.
개별 카드의 day_change도 동일 스냅샷의 NXT 종가를 baseline으로 재계산해 합계와 일관성 유지.
"""
if cash_flow_by_label is None:
cash_flow_by_label = {}
from stock_portfolio_report import consolidate, aggregate_journal, group_by_owner, rebase_to_nxt_close, enrich_journal_realized_pl
rows: list[dict] = []
for label, positions in positions_by_acc.items():
for p in positions:
rows.append({
'account': label,
'stock': p['name'],
'code': p['code'],
'qty': p['qty'],
'avg': p['avg_price'],
'price': p['cur_price'],
'pred_close': p.get('pred_close_pric', 0),
'day_change': p.get('day_change', 0),
'day_change_pct': p.get('day_change_pct', 0.0),
'buy_amount': p['pur_amt'],
'eval_value': p['evlt_amt'],
'profit': p['evltv_prft'],
'profit_rate': p['prft_rt'],
'tdy_buyq': p['tdy_buyq'],
'tdy_sellq': p['tdy_sellq'],
})
# A-4: prev_snap 없을 때만 ka10001 보정. brifing 과 동일 정책.
# 정상 운영(prev_snap 있음)에선 rebase_to_nxt_close 가 NXT-on-NXT 기준 더 정확하게 덮어쓰므로 불필요.
if not prev_snap_all and rows:
from stock_portfolio_report import apply_ka10001_corrections
try:
applied = apply_ka10001_corrections(rows)
sys.stderr.write(f'[ka10001-fallback] prev_snap 없음 → {applied}개 종목 보정\n')
except Exception as e:
sys.stderr.write(f'ka10001 fallback failed: {e}\n')
# 계좌 라벨 정렬 — set 변환의 비결정성 제거 + owner 안에서 일반→ISA 순서 고정.
# suffix(언더바 뒤 또는 본인 라벨 자체)를 기준으로 일반=0, ISA=1, 그 외 사전순.
def _account_label_sort_key(lb: str) -> tuple[int, str]:
suffix = lb.split('_', 1)[1] if '_' in lb else lb
return ({'일반': 0, 'ISA': 1}.get(suffix, 2), lb)
labels = sorted({r['account'] for r in rows} | set(balances.keys()), key=_account_label_sort_key)
owner_groups = group_by_owner(labels)
owner_data: dict = {}
for owner, lbls in owner_groups.items():
owner_rows = [r for r in rows if r['account'] in lbls]
consolidated = consolidate(owner_rows)
owner_journal = aggregate_journal({lb: journal_by_label.get(lb, []) for lb in lbls})
# ka10170이 어제부터 보유한 종목 매도분에 pl_amt=0 박는 케이스 보강 (스냅샷 avg_price 사용)
prev_holdings_for_owner = (prev_snap_all or {}).get(owner, {}).get('holdings') or {}
enrich_journal_realized_pl(owner_journal, prev_holdings_for_owner)
# 당일 실현손익 합계 — 매도분만 (그로스 기준, 수수료·세금 별도)
realized_pl_total = sum(j.get('pl_amt', 0) for j in owner_journal.values() if j.get('sell_qty', 0) > 0)
realized_fees_total = sum(j.get('cmsn_tax', 0) for j in owner_journal.values() if j.get('sell_qty', 0) > 0)
held_codes = {r['code'] for r in consolidated}
for r in consolidated:
j = owner_journal.get(r['code'])
if j:
r['journal'] = j
r['tdy_buyq'] = j['buy_qty']
r['tdy_sellq'] = j['sell_qty']
r['traded_today'] = (j['buy_qty'] + j['sell_qty']) > 0
for code, j in owner_journal.items():
if code in held_codes:
continue
consolidated.append({
'stock': j['name'],
'code': code,
'accounts': list(j['accounts']),
'qty': 0,
'avg': 0,
'price': 0,
'pred_close': 0,
'day_change': 0,
'day_change_pct': 0.0,
'buy_amount': 0,
'eval_value': 0,
'profit': 0,
'profit_rate': 0.0,
'tdy_buyq': j['buy_qty'],
'tdy_sellq': j['sell_qty'],
'traded_today': True,
'phantom': True,
'journal': j,
})
# rebase 직전 raw KRX 종가(kt00018 pred_close_pric)를 별도 키로 보존.
# 이후 ohlc-mini가 KRX/NXT 두 기준을 같이 표시할 때 사용.
# NXT 미상장 코드는 별도 마커로 식별 — KRX==NXT 우연 일치 false-positive 방지.
nxt_inactive_set = set((prev_snap_all or {}).get('_nxt_inactive') or [])
for r in consolidated:
r['pred_close_krx'] = r.get('pred_close', 0)
if r.get('code') in nxt_inactive_set:
r['_nxt_inactive'] = True
# 개별 종목의 day_change/pred_close를 어제 NXT 종가 baseline으로 재계산.
# kt00018 NXT 응답의 pred_close_pric가 KRX 종가로 채워져 raw day_change가 NXT-vs-KRX 갭으로
# 부풀려지는 문제를 해결한다. 합계 KPI는 동일 NXT 스냅샷의 prev_eval과 비교하므로
# 개별 합 ≈ 합계로 자연스럽게 일치한다.
prev_holdings = (prev_snap_all or {}).get(owner, {}).get('holdings') or {}
rebase_to_nxt_close(consolidated, prev_holdings)
total_cost = sum(r['buy_amount'] for r in consolidated)
total_value = sum(r['eval_value'] for r in consolidated)
total_profit = sum(r['profit'] for r in consolidated)
total_profit_rate = (total_profit / total_cost * 100) if total_cost else 0.0
# 예수금은 d2_entra(D+2 정산예수금) — 매수 즉시 차감/매도 즉시 가산되어
# 평가액 변동과 일관성 유지. entr은 D+2 결제 전까지 변하지 않아
# 신규 매수 시 손익이 매수원가만큼 부풀려지는 버그가 있다.
deposit = sum(balances.get(lb, {}).get('d2_entra', 0) for lb in lbls)
total_net = total_value + deposit
traded_today = [r for r in consolidated if r.get('traded_today')]
# 당일 평가손익 = (total_net - prev_net) - net_cash_flow (전날 stock.briefing 20:10 스냅샷 기준).
prev_net = None
if prev_snap_all:
owner_prev = prev_snap_all.get(owner) or {}
prev_eval = sum(v.get('eval_value', 0) for v in prev_holdings.values()) if prev_holdings else None
prev_deposit = owner_prev.get('deposit')
if prev_eval is not None and prev_deposit is not None:
prev_net = prev_eval + prev_deposit
# 당일 외부 입출금 — 손익이 아니므로 day_pl_total 에서 제거.
owner_cash_flows: list[dict] = []
cash_in = 0
cash_out = 0
for lb in lbls:
for f in cash_flow_by_label.get(lb, []) or []:
owner_cash_flows.append({**f, 'label': lb})
if f['io_tp'] == 'IN':
cash_in += f['amount']
elif f['io_tp'] == 'OUT':
cash_out += f['amount']
net_cash_flow = cash_in - cash_out
day_pl_total = (total_net - prev_net - net_cash_flow) if prev_net is not None else None
# 휴장일/주말은 시장 변동이 없어야 자연. 단 NXT 가격 보정으로 어제 스냅샷과 차이가 생겨
# day_pl_total이 0이 아닌 값을 갖게 됨 → 사용자 혼란 차단 위해 None으로 강제(미표시).
if _market_phase_state().get('phase') in ('holiday', 'weekend'):
day_pl_total = None
owner_data[owner] = {
'labels': lbls,
'consolidated': consolidated,
'rows': owner_rows,
'total_cost': total_cost,
'total_value': total_value,
'total_profit': total_profit,
'total_profit_rate': total_profit_rate,
'deposit': deposit,
'total_net': total_net,
'traded_today': traded_today,
'prev_net': prev_net,
'day_pl_total': day_pl_total,
'realized_pl_total': realized_pl_total,
'realized_fees_total': realized_fees_total,
'cash_in': cash_in,
'cash_out': cash_out,
'net_cash_flow': net_cash_flow,
'cash_flows': owner_cash_flows,
}
# 당일정산(phantom) 종목에 현재가 주입 — 매도 시점과 시장 종가 비교용.
# ka10095 batch 1콜로 처리. 실패해도 웹뷰는 정상 응답.
phantom_codes = sorted({
r['code']
for d in owner_data.values()
for r in d['consolidated']
if r.get('phantom') and r.get('code')
})
if phantom_codes:
try:
import kiwoom_client as kc
live_quotes = kc.get_watchlist_quotes(phantom_codes)
except Exception as e:
sys.stderr.write(f'[phantom-quote] fetch failed: {e}\n')
live_quotes = {}
for d in owner_data.values():
for r in d['consolidated']:
if r.get('phantom') and r.get('code'):
q = live_quotes.get(r['code'])
if q:
r['live_price'] = q.get('price', 0)
r['live_change_pct'] = q.get('change_pct', 0.0)
ordered = sorted(owner_data.keys(), key=lambda o: (o != '본인', o))
return ordered, owner_data
def _annotate_card(c: dict, holdings: dict) -> None:
"""카드에 보유 여부·기준가·diff·pct 부여. holdings는 코드 keyed dict.
ref_price/diff는 손익 계산용:
- 보유: 평단 기준 (P&L)
- 감시: 매수가 기준 (entry 거리)
"""
code = (c.get('code') or '').strip()
held = holdings.get(code) if code else None
price = c.get('price')
if held:
c['mode'] = 'held'
c['held_qty'] = held['qty']
c['held_avg_price'] = held['avg_price']
c['held_accounts'] = held['accounts']
c['ref_price'] = held['avg_price']
c['ref_label'] = '평단'
else:
c['mode'] = 'watching'
buy = c.get('buy') if isinstance(c.get('buy'), dict) else {}
c['ref_price'] = (buy or {}).get('primary')
c['ref_label'] = '매수가'
ref = c.get('ref_price')
if price is not None and isinstance(ref, (int, float)) and ref > 0:
c['diff'] = price - ref
c['pct'] = (c['diff'] / ref) * 100
else:
c['diff'] = None
c['pct'] = None
def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
"""한 페이지 로드 단위로 키움 호출을 묶는다.
병렬:
- kt00018(보유종목 전체 계좌)
- kt00001(예수금) × 계좌 수
- ka10170(당일매매일지 전체 계좌)
- ka10095(워치리스트 종목 현재가 batch 1콜, miss 시 ka10001 단건 fallback)
only_owner: '본인' / '가희' 등 owner 지정 시 해당 라벨 계좌만 호출하고 워치리스트 quotes는 skip
(owner 탭 자동 갱신용 — 보유종목 가격은 kt00018 평가가에 포함, 워치리스트와 무관).
어느 분기 실패하든 다른 분기 데이터는 계속 흐른다. 워치리스트가 비어도 holdings 분기는 그대로.
"""
import kiwoom_client as kc
from stock_portfolio_report import derive_owner
try:
accounts = kc.list_accounts()
except Exception as e:
sys.stderr.write(f'list_accounts failed: {e}\n')
accounts = []
all_labels = [a['label'] for a in accounts]
if only_owner:
labels = [lb for lb in all_labels if derive_owner(lb) == only_owner]
else:
labels = all_labels
quote_entries = [] if only_owner else entries
with ThreadPoolExecutor(max_workers=max(len(labels), 1) + 6) as ex:
# NXT 우선 가격 — 한국거래소 마감(15:30) 후에도 NXT 운영(20:00까지)으로 보유 종목 시세가 살아 움직임.
# NXT 미상장 종목은 키움이 KRX 가격으로 자동 fallback. 잔고 평가(eval_value 등)도 NXT 기준 재계산.
positions_f = ex.submit(kc.get_positions_all, exchange='NXT', labels=labels if only_owner else None)
balance_fs = {lb: ex.submit(kc.get_balance, lb) for lb in labels}
journal_f = ex.submit(kc.get_trade_journal_all, None, labels if only_owner else None)
quotes_f = ex.submit(_fetch_quotes_batch, quote_entries)
# 미체결 (ka10075) — 보유/감시 종목에 매수/매도대기 배지 표시용
open_orders_f = ex.submit(kc.get_open_orders_all, labels=labels)
# 당일 입출금 (kt00015 tp='1') — 당일 평가손익 보정용
cash_flow_f = ex.submit(kc.get_cash_flow_all, None, labels if only_owner else None)
try:
positions_by_acc = positions_f.result()
except Exception as e:
sys.stderr.write(f'positions fetch failed: {e}\n')
positions_by_acc = {}
# 가격 보정 — KRX와 NXT 각각 ka10095 batch 호출.
# kiwoom_client.get_watchlist_quotes 의 exchange: 'KRX'=KRX 단독 / 'NX'=NXT 단독 / 'AL'=통합.
# ('NXT' 값은 함수에서 인식 안 되고 KRX 기본으로 떨어짐 — 사용 X)
# phase별 우선 시장:
# - regular(09:00~15:30): KRX 라이브 가격
# - nxt(08:00~09:00, 15:30~20:00): NXT 라이브 가격
# - 그 외(closed/holiday/weekend): kt00018 NXT 응답 그대로 유지
held_codes_set: set[str] = set()
for _items in positions_by_acc.values():
for _p in _items:
if _p.get('code'):
held_codes_set.add(_p['code'])
ohlc_map_krx: dict[str, dict] = {}
ohlc_map_nxt: dict[str, dict] = {}
if held_codes_set:
try:
ohlc_map_krx = kc.get_watchlist_quotes(list(held_codes_set), exchange='KRX')
except Exception as e:
sys.stderr.write(f'KRX positions price fetch failed: {e}\n')
try:
ohlc_map_nxt = kc.get_watchlist_quotes(list(held_codes_set), exchange='NX')
except Exception as e:
sys.stderr.write(f'NXT positions price fetch failed: {e}\n')
_phase = _market_phase_state().get('phase')
active_map = ohlc_map_nxt if _phase == 'nxt' else (ohlc_map_krx if _phase == 'regular' else None)
if active_map:
for _items in positions_by_acc.values():
for _p in _items:
q = active_map.get(_p.get('code'))
if not q:
continue
px = q.get('price', 0)
if not px or px <= 0:
continue
pcp = _p.get('pred_close_pric', 0) or 0
# 활성 시장 응답이 거래가 없는 fallback (price == pred & change == 0)이면 덮어쓰기 X
if pcp and px == pcp and (q.get('change') or 0) == 0:
continue
_p['cur_price'] = px
qty = _p.get('qty', 0)
avg = _p.get('avg_price', 0)
_p['evlt_amt'] = px * qty
if avg > 0:
_p['evltv_prft'] = (px - avg) * qty
_p['prft_rt'] = ((px - avg) / avg) * 100
ch = q.get('change')
if ch is not None:
_p['day_change'] = ch
_p['day_change_pct'] = q.get('change_pct') or 0.0
balances: dict = {}
for lb, fut in balance_fs.items():
try:
balances[lb] = fut.result()
except Exception as e:
sys.stderr.write(f'balance fetch failed [{lb}]: {e}\n')
try:
journal_by_label = journal_f.result()
except Exception as e:
sys.stderr.write(f'journal fetch failed: {e}\n')
journal_by_label = {}
# 휴장일/주말엔 ka10170이 직전 영업일 데이터를 반환 → 당일매매·당일정산·라벨별 실현손익에 어제 거래가 잘못 표시.
import trade_journal as tj
if not tj.is_market_open(datetime.now(tj.KST)):
journal_by_label = {}
# 페이지 RENDER마다 ka10170 응답 그대로 trade_journal.jsonl 적재 (추가 API 호출 X).
# 외부(HTS/MTS 등)에서 매매 후 자산웹 새로고침하면 즉시 거래내역 반영. 21:00 launchd가 최종 덮어씀.
if journal_by_label:
try:
tj.record_trades(journal_by_label, quiet=True, tag='render')
except Exception as e:
sys.stderr.write(f'[render→journal] record_trades failed: {e}\n')
cards = quotes_f.result()
try:
open_orders = open_orders_f.result()
except Exception as e:
sys.stderr.write(f'open orders fetch failed: {e}\n')
open_orders = []
try:
cash_flow_by_label = cash_flow_f.result()
except Exception as e:
sys.stderr.write(f'cash flow fetch failed: {e}\n')
cash_flow_by_label = {}
# 미체결 → 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': [...]}
# orders 원소: {ord_no, side, qty, price, order_type, time, account, exchange}
def _empty_bucket():
return {'buy_qty': 0, 'sell_qty': 0, 'orders': []}
pending_by_owner: dict[str, dict[str, dict]] = {}
pending_global: dict[str, dict] = {}
pending_names_by_code: dict[str, str] = {} # 미보유 종목 카드 렌더용 종목명
pending_buy_lock_by_label: dict[str, int] = {} # 라벨별 매수 미체결 묶임 금액 (지정가만)
for o in open_orders:
code = o.get('code')
if not code:
continue
if o.get('name') and code not in pending_names_by_code:
pending_names_by_code[code] = o['name']
owner = derive_owner(o.get('account', ''))
side = o.get('side')
qty = o.get('unfilled_qty', 0)
if qty <= 0:
continue
ord_entry = {
'ord_no': o.get('ord_no', ''),
'side': side,
'qty': qty,
'price': o.get('order_price', 0),
'order_type': o.get('order_type', ''),
'time': o.get('order_time', ''),
'account': o.get('account', ''),
'exchange': o.get('exchange', ''),
}
bucket = pending_by_owner.setdefault(owner, {}).setdefault(code, _empty_bucket())
g = pending_global.setdefault(code, _empty_bucket())
if side == 'BUY':
bucket['buy_qty'] += qty
g['buy_qty'] += qty
px = o.get('order_price', 0)
if px > 0:
pending_buy_lock_by_label[o.get('account', '')] = (
pending_buy_lock_by_label.get(o.get('account', ''), 0) + qty * px
)
elif side == 'SELL':
bucket['sell_qty'] += qty
g['sell_qty'] += qty
bucket['orders'].append(ord_entry)
g['orders'].append(ord_entry)
holdings_map = _holdings_map_from_positions(positions_by_acc)
for c in cards:
_annotate_card(c, holdings_map)
# 워치리스트 카드는 owner 구분 없음 — pending_global 사용
p = pending_global.get(c.get('code') or '')
if p:
c['pending_buy_qty'] = p['buy_qty']
c['pending_sell_qty'] = p['sell_qty']
c['pending_orders'] = p['orders']
prev_key = None
prev_snap_all: dict = {}
try:
from stock_portfolio_report import SNAPSHOT_FILE, _normalize_prev_snap, load_json
snapshots = load_json(SNAPSHOT_FILE, {})
today_key = datetime.now(KST).strftime('%Y-%m-%d')
prev_keys = sorted(k for k in snapshots if k < today_key)
prev_key = prev_keys[-1] if prev_keys else None
if prev_key:
prev_snap_all = _normalize_prev_snap(snapshots.get(prev_key, {}))
# `_watchlist_nxt` / `_nxt_inactive` 가 어제 스냅샷에 없으면 오늘 스냅샷에서 fallback —
# 장중에 즉시 NXT 컬럼을 보기 위함. 오늘자는 "오늘 20:10 NXT 가격"이라 정확한 어제 마감가는 아니지만 NXT 흐름 확인엔 충분.
today_raw = snapshots.get(today_key) or {}
today_norm = _normalize_prev_snap(today_raw) if today_raw else {}
if not (isinstance(prev_snap_all.get('_watchlist_nxt'), dict) and prev_snap_all.get('_watchlist_nxt')):
if isinstance(today_norm.get('_watchlist_nxt'), dict) and today_norm['_watchlist_nxt']:
prev_snap_all['_watchlist_nxt'] = today_norm['_watchlist_nxt']
if not (isinstance(prev_snap_all.get('_nxt_inactive'), list) and prev_snap_all.get('_nxt_inactive')):
if isinstance(today_norm.get('_nxt_inactive'), list) and today_norm['_nxt_inactive']:
prev_snap_all['_nxt_inactive'] = today_norm['_nxt_inactive']
except Exception as e:
sys.stderr.write(f'snapshot load failed: {e}\n')
# watchlist·interests 카드에 어제 NXT 종가 주입 — ohlc-mini의 '전일(NXT)' 컬럼 baseline.
# stock.briefing 20:10 시점 NXT 가격이 _watchlist_nxt 키에 저장됨. 없는 코드는 0으로 두면 `-` placeholder.
wl_nxt_map = prev_snap_all.get('_watchlist_nxt') if isinstance(prev_snap_all.get('_watchlist_nxt'), dict) else {}
# NXT 미상장 코드 — KRX fallback 차단용. 보유 종목도 같이 표기되어 _pl_row가 lookup.
nxt_inactive_raw = prev_snap_all.get('_nxt_inactive') if isinstance(prev_snap_all.get('_nxt_inactive'), list) else []
nxt_inactive_set = set(nxt_inactive_raw)
if wl_nxt_map or nxt_inactive_set:
for c in cards:
code = c.get('code')
if not code:
continue
if code in nxt_inactive_set:
c['_nxt_inactive'] = True
elif code in wl_nxt_map:
c['_pred_close_nxt'] = wl_nxt_map[code]
# 매수 미체결 묶임 금액을 balances에 라벨별로 주입 (KPI·계좌별 예수금 부연 표시용)
for lb in balances:
balances[lb]['pending_buy_locked'] = pending_buy_lock_by_label.get(lb, 0)
try:
ordered_owners, owner_data = _build_owner_data(positions_by_acc, balances, journal_by_label, prev_snap_all, cash_flow_by_label)
except Exception as e:
sys.stderr.write(f'owner aggregate failed: {e}\n')
traceback.print_exc()
ordered_owners, owner_data = [], {}
# owner별 매수 묶임 합산
for owner_name, od in owner_data.items():
od['pending_buy_locked'] = sum(
balances.get(lb, {}).get('pending_buy_locked', 0) for lb in od.get('labels', [])
)
# consolidated/phantom 행에 미체결 수량 주입 (owner 단위)
for owner_name, od in owner_data.items():
owner_pending = pending_by_owner.get(owner_name, {})
for r in od.get('consolidated', []):
p = owner_pending.get(r.get('code') or '')
if p:
r['pending_buy_qty'] = p['buy_qty']
r['pending_sell_qty'] = p['sell_qty']
r['pending_orders'] = p['orders']
for r in od.get('phantom', []) if isinstance(od.get('phantom'), list) else []:
p = owner_pending.get(r.get('code') or '')
if p:
r['pending_buy_qty'] = p['buy_qty']
r['pending_sell_qty'] = p['sell_qty']
r['pending_orders'] = p['orders']
# 미체결 매수 주문 중 owner 미보유 종목 → owner 패널 별도 섹션 노출.
# consolidated에 들어가지 않은 코드만 추림 (보유 + 당일정산 모두 제외).
unheld_codes_all: set[str] = set()
for owner_name, od in owner_data.items():
present_codes = {r.get('code') for r in od.get('consolidated', []) if r.get('code')}
owner_pending = pending_by_owner.get(owner_name, {})
unheld_rows: list[dict] = []
for code, p in owner_pending.items():
if code in present_codes or p['buy_qty'] <= 0:
continue
unheld_rows.append({
'code': code,
'stock': pending_names_by_code.get(code, ''),
'pending_buy_qty': p['buy_qty'],
'pending_sell_qty': p['sell_qty'],
'pending_orders': list(p['orders']),
})
unheld_codes_all.add(code)
unheld_rows.sort(key=lambda r: -r.get('pending_buy_qty', 0))
od['pending_unheld'] = unheld_rows
# 미보유 미체결 종목 현재가 batch (ka10095). 캐시 hit이면 즉시 반환.
if unheld_codes_all:
try:
unheld_quotes = kc.get_watchlist_quotes(sorted(unheld_codes_all))
except Exception as e:
sys.stderr.write(f'[pending-unheld] quote batch failed: {e}\n')
unheld_quotes = {}
for od in owner_data.values():
for r in od.get('pending_unheld', []):
q = unheld_quotes.get(r['code'])
if q:
r['price'] = q.get('price') or 0
r['day_change'] = q.get('change')
r['day_change_pct'] = q.get('change_pct')
r['open'] = q.get('open') # 미리보기 등락은 시가 대비 → open 필요
# 보유 종목 OHLC batch — 카드 펼치면 보이는 오늘 단일 캔들 + 자세히 보기 mini-table용.
# ohlc_map_krx (exchange='KRX')와 ohlc_map_nxt (exchange='NX'=NXT 단독)는 positions 보정에서 이미 호출 — 재사용.
# 각 컬럼은 그 시장 데이터로 정확히 채움 (서로 섞이지 않도록). primary 단일 캔들은 phase 우선.
if ohlc_map_krx or ohlc_map_nxt:
_phase = _market_phase_state().get('phase')
for od in owner_data.values():
for r in od.get('consolidated', []):
code = r.get('code')
qk = ohlc_map_krx.get(code) if code else None
qn = ohlc_map_nxt.get(code) if code else None
# price 만 있어도 valid — 거래 없는 시간대(시가/고가/저가=0)도 종가만 표시되도록.
qk_valid = bool(qk and qk.get('price'))
qn_valid = bool(qn and qn.get('price'))
if qk_valid:
r['ohlc_krx'] = {
'open': qk['open'], 'high': qk['high'], 'low': qk['low'],
'price': qk.get('price', 0), 'change_pct': qk.get('change_pct', 0.0),
'change': qk.get('change', 0),
}
if qn_valid:
r['ohlc_nxt'] = {
'open': qn['open'], 'high': qn['high'], 'low': qn['low'],
'price': qn.get('price', 0), 'change_pct': qn.get('change_pct', 0.0),
'change': qn.get('change', 0),
}
# 단일 캔들 primary — 4값(open/high/low/price) 모두 있는 응답만 후보. 거래 없는 시간대 0값 응답 배제.
qk_full = qk_valid and qk.get('open') and qk.get('high') and qk.get('low')
qn_full = qn_valid and qn.get('open') and qn.get('high') and qn.get('low')
if _phase == 'regular':
primary = qk if qk_full else (qn if qn_full else None)
else:
primary = qn if qn_full else (qk if qk_full else None)
if primary:
r['ohlc'] = {'o': primary['open'], 'h': primary['high'], 'l': primary['low'], 'c': primary.get('price', 0) or r.get('price', 0)}
return {
'cards': cards,
'balances': balances,
'ordered_owners': ordered_owners,
'owner_data': owner_data,
'journal_by_label': journal_by_label,
'only_owner': only_owner,
}
def _apply_watchlist_action(action: str, stock: str) -> None:
"""delete = pending_delete로 표시. restore = active로 복귀. purge = 완전 삭제.
behive_youtube_digest.watchlist_lock과 동일한 lockfile을 사용해 cron save와의 race를 차단한다.
"""
import behive_youtube_digest as byd
with byd.watchlist_lock():
if not WATCHLIST.exists():
raise KeyError(stock)
data = json.loads(WATCHLIST.read_text())
if stock not in data:
raise KeyError(stock)
if action == 'delete':
data[stock]['status'] = 'pending_delete'
data[stock]['pending_delete_at'] = datetime.now(KST).isoformat()
elif action == 'restore':
data[stock].pop('status', None)
data[stock].pop('pending_delete_at', None)
elif action == 'purge':
data.pop(stock)
else:
raise ValueError(action)
tmp = WATCHLIST.with_suffix('.json.tmp')
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2))
tmp.replace(WATCHLIST)
def _load_alerts_map() -> dict[str, list[str]]:
"""state/watchlist_alerts.json → {stock: [conds]}. 신규/레거시 포맷 모두 호환.
렌더링 전용 read-only — 락 안 잡음(다음 monitor가 신규 포맷으로 일관 정리)."""
if not ALERTS_STATE.exists():
return {}
try:
raw = json.loads(ALERTS_STATE.read_text())
except Exception:
return {}
if not isinstance(raw, dict):
return {}
if isinstance(raw.get('alerts'), dict):
return {k: list(v) for k, v in raw['alerts'].items() if isinstance(v, list)}
# 레거시 date-keyed: 모든 날짜 머지
merged: dict[str, list[str]] = {}
for v in raw.values():
if not isinstance(v, dict):
continue
for s, conds in v.items():
if not isinstance(conds, list):
continue
bucket = merged.setdefault(s, [])
for c in conds:
if isinstance(c, str) and c not in bucket:
bucket.append(c)
return merged
def _apply_alerts_reset(stock: str) -> None:
"""해당 종목의 알림 dedup 기록 전부 삭제 → 다음 monitor 사이클에서 재알림 가능.
watchlist_monitor.alerts_lock과 동일 lockfile 사용. 종목이 state 에 없으면 no-op
(404 아님 — 한 번도 알림 안 떴거나 이미 비어 있는 정상 상태)."""
import watchlist_monitor as wm
with wm.alerts_lock():
state = wm.load_alerts_state()
alerts = state.setdefault('alerts', {})
alerts.pop(stock, None)
tmp = ALERTS_STATE.with_suffix('.json.tmp')
ALERTS_STATE.parent.mkdir(parents=True, exist_ok=True)
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2))
tmp.replace(ALERTS_STATE)
class MultipleMatchError(Exception):
"""다중 매칭. do_POST에서 후보 JSON 응답으로 분기."""
def __init__(self, query: str, candidates: list[dict]):
self.query = query
self.candidates = candidates
super().__init__(query)
def _resolve_stock_fuzzy(query: str) -> dict:
"""정확 매칭(case-sensitive) → substring(case-insensitive) 단일 → 다중이면 후보 응답.
'lg' 입력 시 'LG' 단독 자동 통과하지 않고 'LG전자' 등도 함께 후보로 노출되도록
case-insensitive exact 단계는 제외. 후보 정렬은 ci-exact > prefix > 나머지."""
import kiwoom_client as kc
q = (query or '').strip()
if not q:
raise ValueError('빈 입력')
cache = kc._load_code_cache()
if q in cache:
return cache[q]
if q.isdigit() and len(q) == 6:
try:
return kc.resolve_stock_code(q)
except KeyError:
raise ValueError(f'코드 "{q}" 조회 실패')
ql = q.lower()
matched = [info for name, info in cache.items() if ql in name.lower()]
if len(matched) == 1:
return matched[0]
if len(matched) > 1:
def sort_key(info: dict) -> tuple:
n = info['name'].lower()
if n == ql:
return (0, n)
if n.startswith(ql):
return (1, n)
return (2, n)
matched.sort(key=sort_key)
raise MultipleMatchError(q, matched[:30])
try:
return kc.resolve_stock_code(q)
except KeyError:
raise ValueError(f'"{q}"와 일치하는 종목을 찾을 수 없습니다. 종목명 또는 6자리 종목코드를 입력해주세요.')
_INTEREST_PRICE_LABEL = {'buy_price': '매수가', 'target_price': '목표가', 'stop_price': '손절가'}
def _interests_lock():
"""프로세스간 INTERESTS read-modify-write 직렬화. byd.watchlist_lock 패턴 동일."""
import fcntl as _fcntl
from contextlib import contextmanager as _cm
@_cm
def _ctx():
lock_path = INTERESTS.with_suffix(INTERESTS.suffix + '.lock')
lock_path.parent.mkdir(parents=True, exist_ok=True)
f = open(lock_path, 'a')
try:
_fcntl.flock(f.fileno(), _fcntl.LOCK_EX)
yield
finally:
try:
_fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
finally:
f.close()
return _ctx()
def _stock_tags_lock():
"""STOCK_TAGS 파일 직렬화 — interests_lock 와 동일 패턴, 별도 lock 파일."""
import fcntl as _fcntl
from contextlib import contextmanager as _cm
@_cm
def _ctx():
lock_path = STOCK_TAGS.with_suffix(STOCK_TAGS.suffix + '.lock')
lock_path.parent.mkdir(parents=True, exist_ok=True)
f = open(lock_path, 'a')
try:
_fcntl.flock(f.fileno(), _fcntl.LOCK_EX)
yield
finally:
try:
_fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
finally:
f.close()
return _ctx()
def _load_stock_tags() -> dict:
"""{'by_code': {code: tag}, 'by_name': {name: tag}}. 종목 공용 태그 저장소."""
if not STOCK_TAGS.exists():
return {'by_code': {}, 'by_name': {}}
try:
d = json.loads(STOCK_TAGS.read_text())
if 'by_code' not in d:
d['by_code'] = {}
if 'by_name' not in d:
d['by_name'] = {}
return d
except Exception:
return {'by_code': {}, 'by_name': {}}
def _get_stock_tag(code: str | None, name: str | None) -> dict:
"""{'text': str, 'leader': bool} 반환. 항목 없으면 {}. code 우선 → name fallback.
구버전(문자열) 호환: 'X'{'text': 'X', 'leader': X=='대장'} 변환."""
if not code and not name:
return {}
tags = _load_stock_tags()
for src_key, key in (('by_code', code), ('by_name', name)):
if not key:
continue
v = tags.get(src_key, {}).get(key)
if not v:
continue
if isinstance(v, str):
txt = v.strip()
return {'text': txt, 'leader': txt == '대장'}
if isinstance(v, dict):
txt = (v.get('text') or '').strip()
ldr = bool(v.get('leader'))
if txt or ldr:
return {'text': txt, 'leader': ldr}
return {}
def _tag_chip_html(code: str, name: str, interactive: bool = False) -> str:
"""종목명 옆 태그 칩 — 표시 전용 (클릭 비활성). 수정은 detail 안 '+ 태그' 버튼에서만.
- text 있을 때만 렌더 (leader 단독은 칩 미노출, 토글 상태만 보관)
- leader=True → 빨강(tag-leader), 그 외 → 노랑(tag-chip)
- interactive 인자는 후방 호환 위해 받지만 더 이상 사용 안 함 (항상 span)."""
code_s = (code or '').strip()
name_s = (name or '').strip()
if not code_s and not name_s:
return ''
t = _get_stock_tag(code_s, name_s)
text = t.get('text', '')
leader = bool(t.get('leader'))
if not text:
return ''
cls = 'tag-chip tag-leader' if leader else 'tag-chip'
chip_text = html.escape('#' + text)
return f'<span class="{cls}">{chip_text}</span>'
def _tag_add_button_html(code: str, name: str) -> str:
"""detail 영역(자세히보기 펼침) actions의 '태그' 버튼. 칩 유무와 무관하게 항상 노출.
클릭 시 tag-modal 열리며 현재 text/leader 가 prefill 됨."""
code_s = (code or '').strip()
name_s = (name or '').strip()
if not code_s and not name_s:
return ''
t = _get_stock_tag(code_s, name_s)
text = t.get('text', '')
leader = bool(t.get('leader'))
code_attr = html.escape(code_s, quote=True)
name_attr = html.escape(name_s, quote=True)
return (
f'<button type="button" class="btn-add-tag" data-tag-edit="1"'
f' data-tag-stock="{name_attr}" data-tag-code="{code_attr}"'
f' data-tag-text="{html.escape(text, quote=True)}"'
f' data-tag-leader="{"1" if leader else "0"}"'
f' title="태그 설정">+ 태그</button>'
)
def _info_button_html(code: str, name: str) -> str:
"""detail actions의 '상세' 버튼. 클릭 시 info-modal 열고 /api/stock_info fetch.
종목코드 없으면(키움 조회 불가) 미노출."""
code_s = (code or '').strip()
if not code_s:
return ''
return (
f'<button type="button" class="btn-info" data-info-code="{html.escape(code_s, quote=True)}"'
f' data-info-stock="{html.escape((name or "").strip(), quote=True)}"'
f' title="기업정보 (시총·PER·PBR 등)">📈 상세</button>'
)
def _set_stock_tag(code: str | None, name: str | None, text: str, leader: bool) -> None:
"""{text, leader} 저장. text와 leader 둘 다 빈/False면 항목 제거.
text는 # prefix 자동 strip + 20자 cap."""
text = (text or '').strip().lstrip('#').strip()[:20]
leader = bool(leader)
code = (code or '').strip() or None
name = (name or '').strip() or None
if not code and not name:
raise ValueError('종목코드 또는 종목명 중 하나는 필요합니다')
STOCK_TAGS.parent.mkdir(parents=True, exist_ok=True)
with _stock_tags_lock():
d = _load_stock_tags()
if text or leader:
entry = {'text': text, 'leader': leader}
if code:
d['by_code'][code] = entry
if name:
d['by_name'][name] = entry
else:
if code:
d['by_code'].pop(code, None)
if name:
d['by_name'].pop(name, None)
tmp = STOCK_TAGS.with_suffix('.json.tmp')
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
tmp.replace(STOCK_TAGS)
def _apply_interests_action(action: str, stock: str, payload: dict | None = None) -> None:
"""관심종목 add/delete. 자동 적재 없는 수동 리스트라 pending_delete trash 단계 없이 즉시 삭제."""
INTERESTS.parent.mkdir(parents=True, exist_ok=True)
with _interests_lock():
_apply_interests_action_locked(action, stock, payload)
def _apply_interests_action_locked(action: str, stock: str, payload: dict | None = None) -> None:
data: dict = {}
if INTERESTS.exists():
try:
data = json.loads(INTERESTS.read_text())
except Exception as e:
# 손상된 JSON은 timestamp 백업 후 빈 dict로 시작 — 다음 write로 silent 덮어쓰기 방지
backup = INTERESTS.with_name(INTERESTS.name + f'.corrupt.{int(time.time())}')
try:
INTERESTS.rename(backup)
sys.stderr.write(f'INTERESTS json 손상 — {backup}로 백업: {e}\n')
except Exception:
pass
data = {}
if action == 'add':
payload = payload or {}
code = (payload.get('code') or '').strip()
if not stock and not code:
raise ValueError('종목명 또는 종목코드 중 하나는 입력해주세요')
if code:
# 코드 우선 lookup → 정식 종목명으로 통일. stock+code 동시 입력 시 사용자 stock 오타도 교정.
import kiwoom_client as kc
try:
info = kc.resolve_stock_code(code)
stock = info.get('name') or stock or code
code = info.get('code') or code
except Exception:
if not stock:
stock = code
else:
# 종목명만 입력 — fuzzy 매칭으로 코드 lookup
info = _resolve_stock_fuzzy(stock)
stock = info.get('name') or stock
code = info.get('code', '')
if stock in data:
raise ValueError(f'이미 등록된 종목입니다: {stock} — 수정으로 변경하세요')
entry: dict = {'stock': stock, 'saved_at': datetime.now(KST).isoformat()}
if code:
entry['code'] = code
for src_key, dst_key, sub in (('buy_price', 'buy', 'primary'), ('target_price', 'target', 'low'), ('stop_price', 'stop', 'value')):
raw = (payload.get(src_key) or '').strip()
if not raw:
continue
try:
num = float(raw.replace(',', ''))
except ValueError:
raise ValueError(f'{_INTEREST_PRICE_LABEL[src_key]} 형식 오류: "{raw}" — 숫자만 입력해주세요')
entry[dst_key] = {sub: num, 'raw': f'{int(num):,}' if num == int(num) else f'{num:,}'}
memo = (payload.get('memo') or '').strip()
if memo:
entry['memo'] = memo
entry['notes'] = [memo]
data[stock] = entry
elif action == 'update':
if stock not in data:
raise KeyError(stock)
payload = payload or {}
entry = data[stock]
for src_key, dst_key, sub in (('buy_price', 'buy', 'primary'), ('target_price', 'target', 'low'), ('stop_price', 'stop', 'value')):
if src_key not in payload:
continue
raw = (payload.get(src_key) or '').strip()
if not raw:
entry.pop(dst_key, None)
continue
try:
num = float(raw.replace(',', ''))
except ValueError:
raise ValueError(f'{_INTEREST_PRICE_LABEL[src_key]} 형식 오류: "{raw}" — 숫자만 입력해주세요')
entry[dst_key] = {sub: num, 'raw': f'{int(num):,}' if num == int(num) else f'{num:,}'}
if 'memo' in payload:
memo = (payload.get('memo') or '').strip()
if memo:
entry['memo'] = memo
entry['notes'] = [memo]
else:
entry.pop('memo', None)
entry.pop('notes', None)
data[stock] = entry
elif action == 'delete':
if stock not in data:
raise KeyError(stock)
data.pop(stock)
else:
raise ValueError(f'unknown action: {action}')
tmp = INTERESTS.with_suffix('.json.tmp')
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2))
tmp.replace(INTERESTS)
def _fmt_price_field(field) -> str:
if not isinstance(field, dict):
return '언급 없음'
raw = (field.get('raw') or '').strip()
return raw or '언급 없음'
def _near_buy_tier(c: dict) -> int:
"""감시 모드 매수 가능권 단계.
0 = 해당없음 (매수가 위 5% 초과)
1 = 매수가 위 3~5% (노랑, 멀리서 접근)
2 = 매수가 위 0~3% (주황, 임박)
3 = 매수가 도달/하회 (빨강, 매수권 진입)
"""
if c.get('mode') != 'watching':
return 0
pct = c.get('pct')
if not isinstance(pct, (int, float)):
return 0
if pct > 5:
return 0
if pct > 3:
return 1
if pct > 0:
return 2
return 3
def _color_class(mode: str, price, ref_price, stop_value) -> str:
"""mode 별 색상.
held(보유): 초록=수익, 빨강=손실, 회색=동가
watching(감시): 초록=매수가 도달, 빨강=손절가 하회, 회색=아직 매수가 위 대기
"""
if price is None or not isinstance(ref_price, (int, float)) or ref_price <= 0:
return 'neutral'
if mode == 'held':
if price > ref_price:
return 'up'
if price < ref_price:
return 'down'
return 'neutral'
if isinstance(stop_value, (int, float)) and stop_value > 0 and price <= stop_value:
return 'down'
if price <= ref_price:
return 'up'
return 'neutral'
def _buy_dd_html(buy_field, alerted: set[str]) -> str:
"""매수가 dd 콘텐츠 — raw 라벨 + 레벨별 알림 마커.
levels 가 없거나 raw 가 단일 가격이면 단일 줄. 다중 레벨이면 각 레벨에 마커."""
if not isinstance(buy_field, dict):
return '언급 없음'
raw = (buy_field.get('raw') or '').strip()
levels = [int(lv) for lv in (buy_field.get('levels') or []) if isinstance(lv, (int, float)) and lv > 0]
if not levels:
return html.escape(raw) if raw else '언급 없음'
norm_raw = raw.replace(',', '').replace('', '').replace(' ', '')
single_simple = len(levels) == 1 and norm_raw.isdigit() and int(norm_raw) == levels[0]
parts: list[str] = []
if raw and not single_simple:
parts.append(f'<div class="buy-raw">{html.escape(raw)}</div>')
for lv in levels:
mark = ' <span class="alert-mark">알림됨</span>' if f'buy@{lv}' in alerted else ''
parts.append(f'<div class="buy-level">{lv:,}{mark}</div>')
return ''.join(parts)
def _render_row(c: dict, source: str = 'watchlist') -> str:
stock = html.escape(c.get('stock', ''))
code = html.escape(c.get('code', '') or '')
price = c.get('price')
err = c.get('quote_error')
mode = c.get('mode', 'watching')
ref_price = c.get('ref_price')
ref_label = c.get('ref_label', '매수가')
stop_dict = c.get('stop') if isinstance(c.get('stop'), dict) else {}
cls = _color_class(mode, price, ref_price, (stop_dict or {}).get('value'))
tier = _near_buy_tier(c)
dot_html = f'<span class="dot dot-{tier}" title="매수 가능권 {tier}단계"></span>' if tier else ''
if price is None:
price_cell = '<span class="muted">-</span>'
diff_cell = f'<span class="muted">{html.escape(err) if err else "조회 불가"}</span>'
else:
# 가격 출처 마크 — _krx_price/_nxt_price 비교. NXT 가격이 살아있고 KRX와 다르면 NXT.
krx_p = c.get('_krx_price', 0) or 0
nxt_p = c.get('_nxt_price', 0) or 0
if nxt_p > 0 and (krx_p == 0 or krx_p != nxt_p):
mark = 'NXT'
else:
mark = 'KRX'
mark_html = f'<span class="px-mark px-{mark.lower()}">{mark}</span>'
# 미리보기 등락은 KRX 전일종가 대비 — ka10095의 change_pct가 이미 KRX 기준이라 그대로 사용.
day_pct = c.get('day_change_pct')
if c.get('_show_day_change') and isinstance(day_pct, (int, float)):
d_sign = '+' if day_pct >= 0 else ''
d_cls = 'day-pct up' if day_pct > 0 else ('day-pct down' if day_pct < 0 else 'day-pct neutral')
price_cell = f'{mark_html}{price:,}<span class="{d_cls}">{d_sign}{day_pct:.2f}%</span>'
else:
price_cell = f'{mark_html}{price:,}'
if mode == 'watching' and isinstance(ref_price, (int, float)) and ref_price > 0:
diff_cell = f'<span class="muted small">매수가</span> {ref_price:,.0f}'
else:
diff_cell = ''
if mode == 'held':
qty = c.get('held_qty', 0)
status_cell = f'<span class="badge held">보유 {qty:,}주</span>'
accounts = c.get('held_accounts') or []
avg = c.get('held_avg_price', 0)
held_row = (
f'<span class="lbl">매입평단</span>'
f'<span class="val num">{avg:,}원<span class="muted small"> · {", ".join(accounts)}</span></span>'
)
else:
status_cell = '<span class="badge watching">매수전</span>'
held_row = ''
status_cell += _pending_badges_html(c)
# 당일 OHLC mini-table — ka10095는 pred_close 필드를 안 줘서 `price - day_change` 로 KRX 종가 역산.
# NXT 종가는 stock.briefing 20:10 스냅샷의 `_watchlist_nxt`에서 주입된 `_pred_close_nxt`. 없으면 `-`.
open_ref = c.get('open')
high_ref = c.get('high')
low_ref = c.get('low')
price_ref = c.get('price')
day_chg_ref = c.get('day_change')
pred_close_krx = 0
if isinstance(price_ref, (int, float)) and price_ref > 0 and isinstance(day_chg_ref, (int, float)):
pc = int(price_ref) - int(day_chg_ref)
if pc > 0:
pred_close_krx = pc
# NXT 미상장 종목은 baseline 무효화 → NXT 컬럼 `-` placeholder.
if c.get('_nxt_inactive'):
pred_close_nxt = 0
else:
nxt_ref = c.get('_pred_close_nxt')
pred_close_nxt = int(nxt_ref) if isinstance(nxt_ref, (int, float)) and nxt_ref > 0 else 0
ohlc_mini_html = ''
if all(isinstance(v, (int, float)) and v > 0 for v in (open_ref, high_ref, low_ref, price_ref)):
ohlc_mini_html = _render_ohlc_mini(
int(open_ref), int(high_ref), int(low_ref), int(price_ref),
pred_close_krx,
pred_close_nxt,
)
alerted = set(c.get('_alerted') or []) if source == 'watchlist' else set()
unheld_done = '_unheld_done' in alerted and mode == 'watching'
# 요약 라벨: 발사된 조건 종류로 컴팩트하게. _ prefix(내부 마커) 제외.
_alert_kinds: list[str] = []
if any(a.startswith('buy@') for a in alerted) or unheld_done:
_alert_kinds.append('매수')
if 'target' in alerted:
_alert_kinds.append('목표')
if 'stop' in alerted:
_alert_kinds.append('손절')
if _alert_kinds:
alert_badge = f'<span class="badge alerted">알림 {"·".join(_alert_kinds)}</span>'
else:
alert_badge = ''
upside = c.get('upside_pct')
_target_text = _fmt_price_field(c.get('target'))
if upside is not None and _target_text != '언급 없음':
sign = '+' if upside >= 0 else ''
_target_text = f'{_target_text} ({sign}{upside:g}%)'
target_raw = html.escape(_target_text)
if 'target' in alerted and _target_text != '언급 없음':
target_raw += ' <span class="alert-mark">알림됨</span>'
if source == 'watchlist':
buy_raw = _buy_dd_html(c.get('buy'), alerted)
else:
buy_raw = html.escape(_fmt_price_field(c.get('buy')))
_stop_text = _fmt_price_field(c.get('stop'))
stop_raw = html.escape(_stop_text)
if 'stop' in alerted and _stop_text != '언급 없음':
stop_raw += ' <span class="alert-mark">알림됨</span>'
chart_block = ''
if code:
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
summary_lines = c.get('summary') or []
summary_block = ''
if summary_lines:
items = ''.join(f'<li>{html.escape(s)}</li>' for s in summary_lines)
summary_block = f'<div class="block"><h3>주요내용</h3><ul>{items}</ul></div>'
notes_lines = c.get('notes') or []
notes_block = ''
if notes_lines:
items = ''.join(f'<li>{html.escape(n)}</li>' for n in notes_lines)
notes_block = f'<div class="block"><h3>기타</h3><ul>{items}</ul></div>'
video = c.get('video') or {}
video_block = ''
if video.get('url'):
title = html.escape(video.get('title', '영상'))
video_block = (
f'<div class="block"><h3>출처</h3>'
f'<a class="video-link" href="{html.escape(video["url"])}" target="_blank" rel="noopener">{title} ↗</a>'
f'</div>'
)
saved = html.escape((c.get('saved_at') or '')[:10])
is_pending = c.get('status') == 'pending_delete'
pending_at = html.escape((c.get('pending_delete_at') or '')[:16].replace('T', ' '))
stock_attr = html.escape(c.get('stock', ''), quote=True)
raw_code = c.get('code') or ''
has_trades = bool(raw_code) and raw_code in _load_traded_codes()
trade_btn = (
f'<button type="button" class="btn-trades" data-trade-code="{html.escape(raw_code, quote=True)}" data-trade-stock="{stock_attr}">거래내역</button>'
) if has_trades else ''
if source == 'watchlist' and raw_code:
analyze_btn = (
f'<button type="button" class="btn-add-from-wl" data-modal-open="interest-modal"'
f' data-prefill-stock="{stock_attr}" data-prefill-code="{html.escape(raw_code, quote=True)}"'
f' title="관심종목 추가 모달 열기 (종목명·코드 자동 입력)">+ 관심추가</button>'
)
else:
# 📊 분석은 상세(기업정보) 팝업 안으로 이동 — 카드 actions에선 미노출.
analyze_btn = ''
trade_mark = (
f'<button type="button" class="badge badge-trade btn-trades" data-trade-code="{html.escape(raw_code, quote=True)}" data-trade-stock="{stock_attr}" title="거래내역 보기">거래내역</button>'
) if has_trades and source == 'interests' else ''
# 매매 진입 버튼 — 보유 모드면 매도 default, 그 외는 매수 default. 모달에서 토글 가능.
order_side = 'SELL' if c.get('mode') == 'held' else 'BUY'
order_btn = (
f'<button type="button" class="btn-order" data-order-code="{html.escape(raw_code, quote=True)}" data-order-stock="{stock_attr}" data-order-side="{order_side}" title="매수/매도">💰 거래</button>'
) if raw_code else ''
if source == 'interests':
def _num_attr(field, sub):
if not isinstance(field, dict):
return ''
v = (field or {}).get(sub)
if isinstance(v, (int, float)) and v > 0:
return str(int(v)) if v == int(v) else str(v)
return ''
buy_attr_v = _num_attr(c.get('buy'), 'primary')
target_attr_v = _num_attr(c.get('target'), 'low')
stop_attr_v = _num_attr(c.get('stop'), 'value')
memo_val = c.get('memo') or ''
if not memo_val:
notes = c.get('notes') or []
if notes and isinstance(notes[0], str):
memo_val = notes[0]
edit_attrs = (
f' data-modal-open="interest-edit-modal"'
f' data-edit-stock="{stock_attr}"'
f' data-edit-code="{html.escape(c.get("code") or "", quote=True)}"'
f' data-edit-buy="{html.escape(buy_attr_v, quote=True)}"'
f' data-edit-target="{html.escape(target_attr_v, quote=True)}"'
f' data-edit-stop="{html.escape(stop_attr_v, quote=True)}"'
f' data-edit-memo="{html.escape(memo_val, quote=True)}"'
)
tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '')
info_btn = _info_button_html(c.get('code') or '', c.get('stock') or '')
actions_html = (
'<div class="actions">'
f'{tag_add_btn}'
f'{analyze_btn}'
f'{info_btn}'
f'{trade_btn}'
f'{order_btn}'
f'<button type="button" class="btn-edit"{edit_attrs}>수정</button>'
'</div>'
)
elif is_pending:
actions_html = (
'<div class="actions">'
f'<span class="muted small">삭제예정 {pending_at}</span>'
f'<form method="post" action="/restore"><input type="hidden" name="stock" value="{stock_attr}"><button type="submit" class="btn-restore">복원</button></form>'
f'<form method="post" action="/purge" onsubmit="return confirm(\'완전 삭제하시겠습니까? 되돌릴 수 없습니다.\')"><input type="hidden" name="stock" value="{stock_attr}"><button type="submit" class="btn-purge">완전 삭제</button></form>'
'</div>'
)
cls = f'{cls} trash'
else:
wl_tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '')
wl_info_btn = _info_button_html(c.get('code') or '', c.get('stock') or '')
actions_html = (
'<div class="actions">'
f'{wl_tag_add_btn}'
f'{analyze_btn}'
f'{wl_info_btn}'
f'{trade_btn}'
f'{order_btn}'
f'<form method="post" action="/alerts/reset"><input type="hidden" name="stock" value="{stock_attr}"><button type="submit" class="btn-alerts-reset">알림 리셋</button></form>'
f'<form method="post" action="/delete"><input type="hidden" name="stock" value="{stock_attr}"><button type="submit" class="btn-delete">감시 삭제</button></form>'
'</div>'
)
tag_chip = _tag_chip_html(c.get('code') or '', c.get('stock') or '', interactive=True)
row_key = code or stock
return f'''<details class="row {cls} mode-{mode}" data-row-key="{row_key}">
<summary>
<div class="left">
<div class="line1">{dot_html}<span class="stock">{stock}</span>{tag_chip}{status_cell}{trade_mark}{alert_badge}</div>
</div>
<div class="right">
<div class="price">{price_cell}<span class="caret" aria-hidden="true">▾</span></div>
{f'<div class="diff">{diff_cell}</div>' if diff_cell else ''}
</div>
</summary>
<div class="detail">
{'<div class="alert-notice">최초 알림 완료 — 리셋 전까지 매수 알림 없음</div>' if unheld_done else ''}
<div class="grid2">
{held_row}
<span class="lbl">매수가</span><span class="val">{buy_raw}</span>
<span class="lbl">목표가</span><span class="val">{target_raw}</span>
<span class="lbl">손절가</span><span class="val">{stop_raw}</span>
<span class="lbl">저장일</span><span class="val muted">{saved}</span>
</div>
{ohlc_mini_html}
{_pending_detail_html(c)}
{summary_block}
{notes_block}
{video_block}
{actions_html}
{chart_block}
</div>
</details>'''
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 = '<span class="muted small">휴장 — 변동 없음</span>'
else:
day_pl_html = '<span class="muted small">전날 스냅샷 없음</span>'
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'<span class="{dcls}">{dsign}{day_pl_total:,}{pct_html}</span>'
deposit_pct_html = ''
if d['total_value']:
dep_pct = (d['deposit'] / d['total_value']) * 100
deposit_pct_html = f'<span class="muted small">({dep_pct:.1f}%)</span> '
locked = d.get('pending_buy_locked', 0) or 0
deposit_locked_html = (
f'<span class="muted small"> · 매수 묶임 {locked:,}원</span>' if locked > 0 else ''
)
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>'),
]
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'<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}'))
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))
rows = ''.join(
f'<tr><th>{html.escape(k)}</th><td>{v}</td></tr>' 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'<button type="button" class="btn-trades btn-trades-all" '
f'data-trade-owner="{owner_attr}" data-trade-stock="{label_attr}">'
f'📋 전체 거래내역</button>'
)
return f'''<section class="owner-card">
<div class="owner-title-row">
<div class="owner-title">{html.escape(owner_label_text)}</div>
{trade_btn_html}
</div>
<div class="owner-sub">{html.escape(labels_disp)}</div>
<table class="kpi">{rows}</table>
</section>'''
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 = '<span class="muted small">전날 스냅샷 없음</span>'
else:
dcls = _profit_class(approx_day_pl)
dsign = '+' if approx_day_pl >= 0 else ''
day_pl_html = f'<span class="{dcls}">{dsign}{approx_day_pl:,}원</span><span class="muted small"> · 근사</span>'
deposit_pct_html = ''
if total_value:
dep_pct = (deposit / total_value) * 100
deposit_pct_html = f'<span class="muted small">({dep_pct:.1f}%)</span> '
deposit_locked_html = (
f'<span class="muted small"> · 매수 묶임 {locked:,}원</span>' if locked > 0 else ''
)
kpis = [
('총 평가금액', f'{total_value:,}'),
('총 매입금액', f'{total_cost:,}'),
('총 평가손익', 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}'),
]
if traded_today_codes 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 ''
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
)
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>
<div class="owner-sub">{html.escape(sub_text)}</div>
<table class="kpi">{rows_html}</table>
</section>'''
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'<span class="ohlc-cell-empty{dim_cls}">-</span>'
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'<span class="ohlc-cell-empty{dim_cls}">-</span>'
return f'<span class="ohlc-cell{dim_cls}"><span class="ohlc-price">{price:,}원</span></span>'
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'<span class="ohlc-cell-empty{dim_cls}">-</span>'
cls = 'up' if pct > 0 else ('down' if pct < 0 else 'neutral')
sgn = '+' if pct >= 0 else ''
return (
f'<span class="ohlc-cell{dim_cls}"><span class="ohlc-price">{price:,}원</span>'
f'<span class="ohlc-pct {cls}">{sgn}{pct:.2f}%</span></span>'
)
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 = [
'<div class="ohlc-mini ohlc-dual">',
'<span class="ohlc-th"></span>',
f'<span class="ohlc-th{krx_th_dim}">KRX</span>',
f'<span class="ohlc-th{nxt_th_dim}">NXT</span>',
]
for lbl, key in rows:
parts.append(f'<span class="ohlc-rl">{lbl}</span>')
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('</div>')
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 '<span class="ohlc-pct neutral">-</span>'
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'<span class="ohlc-pct {cls}">{sgn}{pct:.2f}%</span>'
rows = [
('현재가', current),
('시가', o),
('고가', h),
('저가', l),
]
parts = [
'<div class="ohlc-mini">',
'<span class="ohlc-th"></span>',
'<span class="ohlc-th"></span>',
'<span class="ohlc-th">KRX</span>',
'<span class="ohlc-th">NXT</span>',
]
for lbl, p in rows:
parts.append(f'<span class="ohlc-rl">{lbl}</span>')
parts.append(f'<span class="ohlc-price">{p:,}원</span>')
parts.append(_pct_cell(p, pred_krx))
parts.append(_pct_cell(p, pred_nxt))
parts.append('</div>')
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'<svg viewBox="0 0 {width} {height}" width="{width}" height="{height}" class="candle-svg" aria-hidden="true">'
f'<line x1="{cx:.1f}" y1="{wick_top:.1f}" x2="{cx:.1f}" y2="{wick_bot:.1f}" stroke="{color}" stroke-width="1.5"/>'
f'<rect x="{body_x:.1f}" y="{body_top:.1f}" width="{body_w:.1f}" height="{body_h:.1f}" fill="{color}"/>'
f'</svg>'
)
_EMPTY_KV_PAIR = '<dt class="empty"></dt><dd class="empty"></dd>'
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'<span class="badge pending-buy" title="키움 미체결 매수 {buy:,}주 — 자세히보기에서 확인">💰</span>'
)
if sell:
parts.append(
f'<span class="badge pending-sell" title="키움 미체결 매도 {sell:,}주 — 자세히보기에서 확인">💰</span>'
)
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'<dt class="pending-{kind}-label">{label}</dt>'
f'<dd>'
f'<div class="num pending-line-main">{qty:,}주 @ {price_str}</div>'
f'<div class="muted small pending-line-meta">{" · ".join(meta_parts)}</div>'
f'</dd>'
)
pairs: list[str] = []
for o in buys:
pairs.append(_row(o, 'buy'))
for o in sells:
pairs.append(_row(o, 'sell'))
return f'<dl class="pending-detail">{"".join(pairs)}</dl>'
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('<span class="stripe"></span>' for _ in range(tier))
title = f'매입 {buy_amount:,}원 · 등급 {tier}'
return f'<span class="rank-badge rank-{tier}" title="{title}" aria-label="등급 {tier}">{stripes}</span>'
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'<span class="star buy" title="오늘 매수 {r.get("tdy_buyq",0):,}">★</span>')
if r.get('tdy_sellq'):
star_parts.append(f'<span class="star sell" title="오늘 매도 {r.get("tdy_sellq",0):,}">★</span>')
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'<div class="candle-mini">{mini}</div>'
j = r.get('journal') or {}
journal_lines: list[str] = []
summary_journal_html = ''
if j.get('buy_qty'):
journal_lines.append(
f'<dt>매수</dt><dd class="num">{j["buy_avg"]:,}× {j["buy_qty"]:,}주 = {j["buy_amt"]:,}원</dd>'
)
if j.get('sell_qty'):
sell_pl = j.get('pl_amt', 0)
scls = _profit_class(sell_pl)
ssign = '+' if sell_pl >= 0 else ''
journal_lines.append(
f'<dt>매도</dt><dd class="num">{j["sell_avg"]:,}× {j["sell_qty"]:,}주 = {j["sell_amt"]:,}원</dd>'
)
journal_lines.append(
f'<dt>실현손익</dt><dd class="num"><span class="{scls}">{ssign}{sell_pl:,}'
f'({ssign}{j["prft_rt"]:.2f}%)</span><span class="muted small"> · 수수료·세금 {j["cmsn_tax"]:,}원</span></dd>'
)
# 부분매도 케이스 — 기본 카드 뷰에서도 실현손익이 보이도록 별도 줄로 표시.
# 금액이 크면 비중과 같은 줄에서 폭이 터지므로 line2 를 한 줄 더 분리.
summary_journal_html = (
f'<div class="line2"><span class="muted small">매도 {j["sell_qty"]:,}주 실현 '
f'<span class="{scls}">{ssign}{sell_pl:,}원</span></span></div>'
)
pl_pairs: list[str] = [
f'<dt>평가손익</dt><dd class="num"><span class="{pcls}">{sign}{profit:,}원 ({sign}{profit_rate:.2f}%)</span></dd>',
]
if day_change:
dcls = _profit_class(day_change)
dsign = '+' if day_change >= 0 else ''
day_value = day_change * qty
dvsign = '+' if day_value >= 0 else ''
pl_pairs.append(
f'<dt>평가금액 변동</dt><dd class="num"><span class="{dcls}">{dvsign}{day_value:,}원</span></dd>'
)
pl_pairs.append(
f'<dt>당일 등락</dt><dd class="num"><span class="{dcls}">{dsign}{day_change:,}'
f'({dsign}{day_change_pct:.2f}%)</span></dd>'
)
# 가격 출처 마크 — 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'<span class="px-mark px-{mark.lower()}">{mark}</span>'
# 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:,}<span class="{d_cls}">{d_sign}{d_pct_val:.2f}%</span>'
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'<div class="block chart-svg" data-chart-code="{code}"></div>'
left_pairs = [
f'<dt>종목코드</dt><dd class="muted">{code}</dd>',
f'<dt>현재가</dt><dd class="num">{price:,}원</dd>',
f'<dt>평단가</dt><dd class="num">{avg:,}원</dd>',
]
right_pairs = [
f'<dt>보유수량</dt><dd class="num">{qty:,}주</dd>',
f'<dt>매입금액</dt><dd class="num">{buy_amount:,}원</dd>',
f'<dt>평가금액</dt><dd class="num">{eval_value:,}원</dd>',
]
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'<div class="actions">'
f'{tag_add_btn}'
f'{_info_button_html(r.get("code") or "", r.get("stock") or "")}'
f'<button type="button" class="btn-trades" data-trade-code="{code}" data-trade-stock="{stock}">거래내역</button>'
f'<button type="button" class="btn-order" data-order-code="{code}" data-order-stock="{html.escape(stock, quote=True)}" data-order-side="SELL" title="매수/매도">💰 거래</button>'
f'</div>'
) if code else ''
tag_chip = _tag_chip_html(r.get('code') or '', r.get('stock') or '', interactive=True)
return f'''<details class="row {pcls} mode-held" data-row-key="{row_key}">
<summary>
<div class="left">
<div class="line1">{_buy_rank_badge(buy_amount)}<span class="stock">{stock}</span>{tag_chip}{star}<span class="code">{accounts}</span></div>
<div class="line2"><span class="badge held">보유 {qty:,}주</span>{_pending_badges_html(r)}<span class="muted small">비중 {weight:.2f}%</span></div>
{summary_journal_html}
</div>
<div class="right">
<div class="price">{price_html}<span class="caret" aria-hidden="true">▾</span></div>
<div class="ref">평단 {avg:,}</div>
<div class="diff">{sign}{profit:,}<span class="pct">{sign}{profit_rate:.2f}%</span></div>
</div>
{candle_summary}
</summary>
<div class="detail">
<dl>{dl_inner}</dl>
<dl class="pl-summary">{pl_dl_inner}</dl>
{ohlc_mini_html}
{_pending_detail_html(r)}
{trade_btn}
{chart_block}
</div>
</details>'''
def _render_phantom_row(r: dict) -> str:
"""잔고에 없는 round-trip / 풀매도 거래. 실현손익만 표시."""
stock = html.escape(r['stock'])
code = html.escape(r.get('code', '') or '')
accounts = html.escape('+'.join(r.get('accounts', [])))
j = r.get('journal') or {}
pl = j.get('pl_amt', 0)
pcls = _profit_class(pl)
sign = '+' if pl >= 0 else ''
rate = j.get('prft_rt', 0.0)
pairs: list[str] = [f'<dt>종목코드</dt><dd class="muted">{code}</dd>']
live_price = r.get('live_price') or 0
if live_price:
lpct = r.get('live_change_pct', 0.0)
lsign = '+' if lpct >= 0 else ''
lcls = _profit_class(lpct)
pairs.append(
f'<dt>현재가</dt><dd class="num">{live_price:,}'
f'<span class="{lcls} small">({lsign}{lpct:.2f}%)</span></dd>'
)
if j.get('sell_qty'):
pairs.append(
f'<dt>매도</dt><dd class="num">{j["sell_avg"]:,}× {j["sell_qty"]:,}주 = {j["sell_amt"]:,}원</dd>'
)
pairs.append(
f'<dt>실현손익</dt><dd class="num"><span class="{pcls}">{sign}{pl:,}'
f'({sign}{rate:.2f}%)</span><span class="muted small"> · 수수료·세금 {j["cmsn_tax"]:,}원</span></dd>'
)
# 어제 보유 → 오늘 풀매도 케이스: 어제 스냅샷의 평단을 '매수 평단'으로 노출
prev_avg = j.get('prev_avg', 0)
if prev_avg > 0 and not j.get('buy_qty'):
pairs.append(
f'<dt>매수 평단</dt><dd class="num">{prev_avg:,}원<span class="muted small"> · 어제 잔고 기준</span></dd>'
)
if j.get('buy_qty'):
pairs.append(
f'<dt>매수</dt><dd class="num">{j["buy_avg"]:,}× {j["buy_qty"]:,}주 = {j["buy_amt"]:,}원</dd>'
)
chart_block = ''
if code:
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
row_key = code or stock
kind_label = '단타' if j.get('buy_qty') else '전량매도'
return f'''<details class="row {pcls} mode-phantom" data-row-key="{row_key}">
<summary>
<div class="left">
<div class="line1"><span class="stock">{stock}</span><span class="badge phantom">당일정산</span>{_pending_badges_html(r)}<span class="code">{accounts}</span></div>
<div class="line2"><span class="muted small">{kind_label} · 매도 {j.get("sell_avg", 0):,}× {j.get("sell_qty", 0):,}주</span></div>
{f'<div class="line2"><span class="muted small">현재 {live_price:,}원</span></div>' if live_price else ''}
</div>
<div class="right">
<div class="price">실현 {sign}{pl:,}원<span class="caret" aria-hidden="true">▾</span></div>
<div class="diff"><span class="{pcls}">{sign}{rate:.2f}%</span></div>
</div>
</summary>
<div class="detail">
<dl>{''.join(pairs)}</dl>
{_pending_detail_html(r)}
{chart_block}
</div>
</details>'''
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'<span class="{gcls}">{gsign}{gap:,.0f}<span class="pct">{gsign}{gap_pct:.2f}%</span></span>'
else:
diff_html = ''
price_html = f'{price:,}' if price else '<span class="muted">-</span>'
summary_line2 = f'<span class="muted small">매수 대기 {buy_qty:,}'
if avg_buy:
summary_line2 += f' · 평균 주문가 {avg_buy:,}'
if qty and avg:
summary_line2 += f' · 보유 {qty:,}주 @ {avg:,}'
summary_line2 += '</span>'
chart_block = ''
if code:
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
detail_pairs: list[str] = [f'<dt>종목코드</dt><dd class="muted">{code}</dd>']
if price:
detail_pairs.append(f'<dt>현재가</dt><dd class="num">{price:,}원</dd>')
if avg:
detail_pairs.append(f'<dt>평단가</dt><dd class="num">{avg:,}원</dd>')
if avg_buy:
detail_pairs.append(f'<dt>주문 평균가</dt><dd class="num">{avg_buy:,}원</dd>')
if qty:
detail_pairs.append(f'<dt>보유 수량</dt><dd class="num">{qty:,}주</dd>')
detail_pairs.append(f'<dt>매수 대기</dt><dd class="num">{buy_qty:,}주</dd>')
row_key = (code or stock) + ':pending-buy'
return f'''<details class="row neutral mode-pending-buy" data-row-key="{row_key}">
<summary>
<div class="left">
<div class="line1"><span class="stock">{stock}</span><span class="badge pending-buy" title="키움 미체결 매수">매수등록</span><span class="code">{accounts}</span></div>
<div class="line2">{summary_line2}</div>
</div>
<div class="right">
<div class="price">{price_html}<span class="caret" aria-hidden="true">▾</span></div>
{f'<div class="diff">{diff_html}</div>' if diff_html else ''}
</div>
</summary>
<div class="detail">
<dl>{''.join(detail_pairs)}</dl>
{_pending_detail_html(r)}
{chart_block}
</div>
</details>'''
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'<span class="{gcls}">{gsign}{gap:,.0f}<span class="pct">{gsign}{gap_pct:.2f}%</span></span>'
else:
diff_html = ''
price_html = f'{price:,}' if price else '<span class="muted">-</span>'
summary_line2 = f'<span class="muted small">매도 대기 {sell_qty:,}'
if avg_sell:
summary_line2 += f' · 평균 주문가 {avg_sell:,}'
if qty and avg:
summary_line2 += f' · 보유 {qty:,}주 @ {avg:,}'
summary_line2 += '</span>'
chart_block = ''
if code:
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
detail_pairs: list[str] = [f'<dt>종목코드</dt><dd class="muted">{code}</dd>']
if price:
detail_pairs.append(f'<dt>현재가</dt><dd class="num">{price:,}원</dd>')
if avg:
detail_pairs.append(f'<dt>평단가</dt><dd class="num">{avg:,}원</dd>')
if avg_sell:
detail_pairs.append(f'<dt>주문 평균가</dt><dd class="num">{avg_sell:,}원</dd>')
if qty:
detail_pairs.append(f'<dt>보유 수량</dt><dd class="num">{qty:,}주</dd>')
detail_pairs.append(f'<dt>매도 대기</dt><dd class="num">{sell_qty:,}주</dd>')
row_key = (code or stock) + ':pending-sell'
return f'''<details class="row neutral mode-pending-sell" data-row-key="{row_key}">
<summary>
<div class="left">
<div class="line1"><span class="stock">{stock}</span><span class="badge pending-sell" title="키움 미체결 매도">매도등록</span><span class="code">{accounts}</span></div>
<div class="line2">{summary_line2}</div>
</div>
<div class="right">
<div class="price">{price_html}<span class="caret" aria-hidden="true">▾</span></div>
{f'<div class="diff">{diff_html}</div>' if diff_html else ''}
</div>
</summary>
<div class="detail">
<dl>{''.join(detail_pairs)}</dl>
{_pending_detail_html(r)}
{chart_block}
</div>
</details>'''
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:,}<span class="{d_cls}">{d_sign}{day_pct:.2f}%</span>'
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'<span class="{dcls}">{dsign}{diff:,.0f}<span class="pct">{dsign}{dpct:.2f}%</span></span>'
else:
diff_html = ''
else:
price_html = '<span class="muted">-</span>'
diff_html = ''
summary_line2 = f'<span class="muted small">매수 대기 {buy_qty:,}'
if avg_buy:
summary_line2 += f' · 평균 주문가 {avg_buy:,}'
summary_line2 += '</span>'
chart_block = ''
if code:
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
detail_pairs: list[str] = [f'<dt>종목코드</dt><dd class="muted">{code}</dd>']
if isinstance(price, (int, float)) and price:
detail_pairs.append(f'<dt>현재가</dt><dd class="num">{price:,}원</dd>')
if avg_buy:
detail_pairs.append(f'<dt>주문 평균가</dt><dd class="num">{avg_buy:,}원</dd>')
detail_pairs.append(f'<dt>매수 대기</dt><dd class="num">{buy_qty:,}주</dd>')
row_key = code or stock
return f'''<details class="row neutral mode-pending-unheld" data-row-key="{row_key}">
<summary>
<div class="left">
<div class="line1"><span class="stock">{stock}</span><span class="badge pending-buy" title="키움 미체결 매수">매수등록</span><span class="code">{acc_text}</span></div>
<div class="line2">{summary_line2}</div>
</div>
<div class="right">
<div class="price">{price_html}<span class="caret" aria-hidden="true">▾</span></div>
{f'<div class="diff">{diff_html}</div>' if diff_html else ''}
</div>
</summary>
<div class="detail">
<dl>{''.join(detail_pairs)}</dl>
{_pending_detail_html(r)}
{chart_block}
</div>
</details>'''
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'<div class="net-chart" data-chart-days="{selected_days}" data-chart-unit="{html.escape(selected_unit)}" data-chart-mode="{html.escape(mode)}">'
f'<div class="chart-empty">{msg}</div>'
'</div>'
)
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'<line class="grid" x1="{L}" x2="{L + iw}" y1="{gy:.2f}" y2="{gy:.2f}" />')
label = f'{int(gv) // 1_000_000:,}M' if abs(gv) >= 1_000_000 else f'{int(gv) // 1000:,}K'
grid_lines.append(f'<text class="axis" x="{L - 6}" y="{gy + 3.5:.2f}" text-anchor="end">{html.escape(label)}</text>')
# 0원 기준선 — 손익 0 높이가 차트 범위 안일 때만 (어디부터 적자/흑자인지 표시). 순자산은 항상 양수라 자동 미표시.
# 기준선(가로선)은 유지하고 '0' 텍스트 라벨만 제거 (관리자님 요청 — M/K 라벨과 겹쳐 보기 번잡).
zero_line = ''
if y_lo <= 0 <= y_hi:
zy = y_at(0)
zero_line = (
f'<line class="zero-line" x1="{L}" x2="{L + iw}" y1="{zy:.2f}" y2="{zy:.2f}" />'
)
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'<text class="axis" x="{x_at(i):.2f}" y="{H - 8:.2f}" text-anchor="middle">{html.escape(date_str)}</text>'
)
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'<line class="cf-line" x1="{cf_x:.2f}" x2="{cf_x:.2f}" y1="{T:.2f}" y2="{T + ih:.2f}" '
f'stroke="{cf_color}" stroke-width="1" stroke-dasharray="3 3" opacity="0.55"/>'
)
cf_markers.append(
f'<text class="cf-label" x="{cf_x:.2f}" y="{T - 2:.2f}" text-anchor="middle" '
f'fill="{cf_color}" font-size="14" font-weight="600">{html.escape(cf_label)}</text>'
)
# per_period: 각 기간 점마다 세로 구분선 + 작은 마커 — 기간별 손익이 비슷해 라인이 평평해도 기간 경계를 읽을 수 있게.
# (누적/순자산은 연속 곡선이라 구분선 불필요 — period 모드만)
period_seps = []
point_dots = []
for _i, (px, py) in enumerate(pts):
if per_period:
# 구분선은 기간손익 전용. 점 색 = 그 기간 손익 부호 (이익=빨강 / 손실=파랑 / 0=회색).
period_seps.append(
f'<line class="period-sep" x1="{px:.2f}" x2="{px:.2f}" y1="{T:.2f}" y2="{T + ih:.2f}" />'
)
_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'<circle class="pt-dot" cx="{px:.2f}" cy="{py:.2f}" r="2.6" fill="{_dot_color}"/>')
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)}">',
'<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"/>'
f'<stop offset="100%" stop-color="{line_color}" stop-opacity="0"/>'
'</linearGradient>'
'</defs>',
''.join(grid_lines),
zero_line,
''.join(period_seps),
]
if area_d:
svg_parts.append(f'<path class="area" d="{area_d}" fill="url(#{fill_id})"/>')
# 입출금 수직선은 line/area 위, dot 아래 — 추세선 위에 살짝 비치고 사용자 데이터 점은 가리지 않음.
if cf_markers:
svg_parts.append(''.join(cf_markers))
if baseline_dashed_d:
svg_parts.append(f'<path class="line dashed" d="{baseline_dashed_d}" stroke="{line_color}" />')
if solid_d:
svg_parts.append(f'<path class="line" d="{solid_d}" stroke="{line_color}" />')
if dashed_d:
svg_parts.append(f'<path class="line dashed" d="{dashed_d}" stroke="{line_color}" />')
if point_dots:
svg_parts.append(''.join(point_dots))
if has_live:
svg_parts.append(
f'<circle class="live-halo" cx="{last_x:.2f}" cy="{last_y:.2f}" r="6" fill="{line_color}" fill-opacity="0.18"/>'
)
svg_parts.append(
f'<circle class="{last_dot_class}" cx="{last_x:.2f}" cy="{last_y:.2f}" r="3.6" fill="{line_color}"/>'
)
# 전고점 dot — 그려진 라인(plot_values) 의 max 시점에 노란 마커. 마지막 포인트와 같으면 last_dot 으로 충분해 생략.
peak_i = max(range(len(plot_values)), key=lambda i: plot_values[i])
if peak_i != n - 1:
peak_x, peak_y = pts[peak_i]
svg_parts.append(
f'<circle class="peak-dot" cx="{peak_x:.2f}" cy="{peak_y:.2f}" r="3.6" fill="#fbbf24"/>'
)
# 신고가 마커 — 오늘(마지막) 그려진 값이 이전 모든 포인트를 strict 하게 초과하면 last dot 위에 금색 ring + 라벨.
# per_period면 '역대 최고 기간 손익', 아니면 누적/순자산 신고가.
if n >= 2 and plot_values[-1] > max(plot_values[:-1]):
svg_parts.append(
f'<circle class="ath-ring" cx="{last_x:.2f}" cy="{last_y:.2f}" r="7" fill="none" '
f'stroke="#fbbf24" stroke-width="1.6"/>'
)
_ath_label_y = max(last_y - 14, T + 12)
svg_parts.append(
f'<text class="ath-label" x="{last_x:.2f}" y="{_ath_label_y:.2f}" text-anchor="middle" '
f'fill="#fbbf24" font-size="14" font-weight="700">신고가</text>'
)
svg_parts.append(''.join(x_labels))
svg_parts.append(
'<g class="nw-hover" opacity="0" pointer-events="none">'
f'<line class="nw-hover-line" x1="0" x2="0" y1="{T}" y2="{T + ih}" />'
'<circle class="nw-hover-dot" cx="0" cy="0" r="3.6" />'
'</g>'
)
svg_parts.append('</svg>')
_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 = (
'<div class="net-chart-tip" aria-hidden="true">'
'<button type="button" class="tip-close" data-nw-step="clear" aria-label="닫기">✕</button>'
'<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>'
# 입출금 행 — 클라이언트 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>'
)
head_html = (
'<div class="net-chart-head">'
f'<span class="net-chart-title">{html.escape(title)}</span>'
f'<span class="net-chart-delta {delta_class}">{html.escape(delta_label)}</span>'
'</div>'
)
foot_html = (
'<div class="net-chart-foot">'
f'<span class="muted">{html.escape(range_label)}</span>'
'<span class="net-chart-foot-r">'
'<button type="button" class="nw-nav" data-nw-step="prev" aria-label="이전 날짜">◀</button>'
'<button type="button" class="nw-nav" data-nw-step="next" aria-label="다음 날짜">▶</button>'
f'<span class="net-chart-last">{html.escape(last_label_v)}</span>'
'</span>'
'</div>'
)
return (
f'<div class="net-chart" data-pts="{pts_attr}" data-chart-days="{selected_days}" data-chart-unit="{html.escape(selected_unit)}" data-chart-mode="{html.escape(mode)}">'
+ head_html
+ ''.join(svg_parts)
+ tip_html
+ foot_html
+ '</div>'
)
def _render_owner_panel(owner: str, d: dict, balances: dict, owner_label_text: str, show_day_change: bool = False, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT) -> str:
# 순자산 차트는 자산정보 탭(_render_summary_panel)로 통합 이동. owner 탭엔 KPI + 보유종목 흐름만.
# chart_days/chart_unit 인자는 시그니처 호환 위해 보존 (호출처 영향 최소).
parts = [_render_owner_kpi(owner, d, owner_label_text)]
cash_lines: list[str] = []
for lb in d['labels']:
b = balances.get(lb)
if not b:
continue
lk = b.get('pending_buy_locked', 0) or 0
lock_html = f'<span class="muted small"> · 매수 묶임 {lk:,}원</span>' if lk > 0 else ''
cash_lines.append(
f'<li><span class="muted">[{html.escape(lb)}]</span> 예수금 {b["d2_entra"]:,}{lock_html}</li>'
)
if cash_lines:
parts.append(
'<div class="section-label">계좌별 예수금</div>'
f'<ul class="cash-list">{"".join(cash_lines)}</ul>'
)
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'<div class="section-label">당일 매매<span class="count">{len(traded_held) + len(phantoms)}</span></div>'
)
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'<div class="section-label">매수등록<span class="count">{buy_total}</span></div>'
)
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'<div class="section-label">매도등록<span class="count">{len(pending_sell_held)}</span></div>'
)
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(
'<div class="holdings-toggle-wrap">'
'<div class="account-view-pane" data-account-view-pane="consolidated">'
f'<div class="section-label">보유 종목<span class="count">{held_count}</span></div>'
+ ('\n'.join(consolidated_rows_html) if consolidated_rows_html else '<div class="empty">보유 종목 데이터가 없습니다.</div>')
+ '</div>'
'<div class="account-view-pane" data-account-view-pane="by-account" hidden>'
f'<div class="section-label">보유 종목<span class="count">{acc_count}</span></div>'
+ ('\n'.join(by_account_rows_html) if by_account_rows_html else '<div class="empty">보유 종목 데이터가 없습니다.</div>')
+ '</div>'
'</div>'
)
if not held and not phantoms:
parts.append('<div class="empty">보유 종목 데이터가 없습니다.</div>')
return '\n'.join(parts)
_CSS = '''
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; padding: 0; overscroll-behavior-y: none; touch-action: manipulation; }
body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Apple SD Gothic Neo", sans-serif; background: #0b0d12; color: #e6e6e6; -webkit-font-smoothing: antialiased; padding-bottom: calc(env(safe-area-inset-bottom) + 28px); -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
/* 하단 고정 시장 지수 — 코스피/코스닥. viewport 최하단에 박힘. iOS 홈 인디케이터 영역만 safe-area 패딩으로 양보. */
/* 증권사 스타일 무한 가로 스크롤. 같은 항목 세트를 2번 렌더 후 -50% 이동 → seamless loop. */
.market-ticker {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
background: rgba(11,13,18,0.96);
backdrop-filter: saturate(160%) blur(10px); -webkit-backdrop-filter: saturate(160%) blur(10px);
border-top: 1px solid #1f2330;
padding: 5px 0;
padding-bottom: calc(5px + env(safe-area-inset-bottom));
font-size: 10.5px;
line-height: 1.2;
font-variant-numeric: tabular-nums;
color: #e6e6e6;
overflow: hidden;
white-space: nowrap;
}
.ticker-track {
display: inline-flex;
width: max-content;
/* intro(5s linear): viewport 오른쪽 끝(100vw)에서 자연 위치(0)까지 일정 속도로 슬라이드 인 — 첫 로딩 시 KOSPI 잘림 방지.
ease-out은 시작 순간이 peak 속도라 "엄청 빠름" 인상을 줘서 linear로 고정.
scroll(20s linear infinite, 5s 지연): intro 끝난 직후 무한 가로 스크롤로 인계. translateX(0)에서 매끄럽게 연결. */
animation: ticker-intro 5s linear, ticker-scroll 20s linear 5s infinite;
will-change: transform;
}
.ticker-set { display: inline-flex; align-items: center; gap: 20px; flex-shrink: 0; }
.ticker-sep { display: inline-flex; align-items: center; padding: 0 22px; color: #4a5060; font-size: 14px; font-weight: 700; user-select: none; }
@keyframes ticker-intro {
from { transform: translateX(100vw); }
to { transform: translateX(0); }
}
@keyframes ticker-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
/* PC 마우스 호버 시만 일시정지. 모바일 :active는 페이지 터치 후 잔류해서 ticker가 멈춰버리는 이슈로 제외. */
@media (hover: hover) {
.market-ticker:hover .ticker-track { animation-play-state: paused; }
}
.market-ticker .idx { display: inline-flex; align-items: baseline; gap: 4px; font-size: 10.5px; }
.market-ticker .idx-label { color: #8b8f9a; font-weight: 500; font-size: 10.5px; }
.market-ticker .idx-price { color: #f0f0f0; font-weight: 500; font-size: 10.5px; }
.market-ticker .idx-delta { font-weight: 400; font-size: 10.5px; }
.market-ticker .idx-delta.up { color: #ff4d5e; }
.market-ticker .idx-delta.down { color: #4d9bff; }
.market-ticker .idx-delta.flat { color: #8b8f9a; }
.market-ticker .idx-empty { color: #5a5f6c; font-size: 10px; padding: 0 14px; }
/* ── pull-to-refresh ── */
/* .page가 손가락 따라 translateY로 내려감 → topbar+컨텐츠 다 함께 내려가고 그 위로 빈공간 노출.
.page.dragging 일 때만 topbar sticky를 일시 해제(position:static)해서 wrapper 변환에 묶이게 함.
평상시(non-dragging)엔 topbar 그대로 sticky → 정상 스크롤 시 헤더 박혀있음.
.ptr은 .page 내부 absolute top:-50 → 평소엔 viewport 위에 숨어있다가 .page 내려가면 갭에 emerge. */
.page {
position: relative;
background: #0b0d12;
transition: transform 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* will-change: transform이 항상 켜져있으면 .page가 containing block이 되어
자식 .topbar의 position:sticky가 viewport가 아닌 .page 좌표계에 갇혀 스크롤을 따라 올라간다.
드래그 시점에만 promote — GPU 가속이 필요한 순간에만 적용. */
.page.dragging { transition: none; will-change: transform; }
.page.dragging .topbar { position: static; }
.ptr {
position: absolute;
/* 노치에선 env(safe-area-inset-top)만큼 더 아래로 내려서 시계·카메라 영역 회피 */
top: calc(-52px + env(safe-area-inset-top, 0px));
left: 50%;
width: 40px; height: 40px; margin-left: -20px;
background: rgba(28, 34, 48, 0.95);
border: 1px solid #2a3142;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
color: #8b8f9a;
opacity: 0;
pointer-events: none;
z-index: 25; /* topbar(20)보다 위 — ptr이 topbar 영역 위로 떠올라 가려지지 않음 */
font-variant-numeric: tabular-nums;
transition: color 0.18s, border-color 0.18s, background 0.18s, box-shadow 0.2s;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
}
.ptr .arrow { font-size: 17px; font-weight: 700; line-height: 1; }
.ptr .spinner {
display: none;
width: 18px; height: 18px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
}
.ptr.committed {
color: #ff4d5e;
border-color: #ff4d5e;
background: rgba(255, 77, 94, 0.20);
box-shadow: 0 4px 16px rgba(255, 77, 94, 0.35);
}
.ptr.spinning .arrow { display: none; }
.ptr.spinning .spinner { display: block; animation: ptr-spin 0.7s linear infinite; }
@keyframes ptr-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.topbar {
position: sticky; top: 0; z-index: 20;
background: rgba(11,13,18,0.94); backdrop-filter: saturate(160%) blur(10px); -webkit-backdrop-filter: saturate(160%) blur(10px);
border-bottom: 1px solid #1f2330;
}
header.top {
padding: 10px 16px; padding-top: max(10px, env(safe-area-inset-top));
display: flex; justify-content: space-between; align-items: center; gap: 10px;
}
header.top .titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
header.top h1 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; color: #f0f0f0; animation: refresh-flash 0.8s ease-out; }
header.top .meta { font-size: 11px; color: #8b8f9a; font-variant-numeric: tabular-nums; animation: refresh-flash-meta 0.8s ease-out; }
/* 페이지 로드(첫 진입·새로고침) 시 1회 발동. 탭 클릭(hashchange)은 reload 아니므로 발동 안 함. */
@keyframes refresh-flash {
0%, 20% { color: #ff4d5e; }
100% { color: #f0f0f0; }
}
@keyframes refresh-flash-meta {
0%, 20% { color: #ff4d5e; }
100% { color: #8b8f9a; }
}
.refresh {
flex: none; display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 36px; border-radius: 8px;
background: #1c2230; border: 1px solid #2a3142; color: #e6e6e6;
font-size: 17px; text-decoration: none; user-select: none;
transition: background 0.15s, transform 0.1s;
}
.refresh:hover { background: #232a3b; }
.refresh:active { transform: scale(0.95); background: #2a3245; }
.refresh.spinning { background: rgba(255,77,94,0.15); border-color: #ff4d5e; color: #ff4d5e; }
.top-actions { display: flex; align-items: center; gap: 8px; flex: none; }
.auto-toggle {
flex: none; display: inline-flex; align-items: center; gap: 6px;
padding: 0 10px; height: 36px; border-radius: 8px;
background: #1c2230; border: 1px solid #2a3142; color: #8b8f9a;
font: 600 12px/1 inherit;
font-variant-numeric: tabular-nums;
cursor: pointer; user-select: none;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.auto-toggle:hover { background: #232a3b; }
.auto-toggle:active { transform: scale(0.95); }
.auto-toggle .auto-dot {
width: 7px; height: 7px; border-radius: 50%;
background: #5a5f6c;
transition: background 0.15s, box-shadow 0.15s;
}
.auto-toggle[aria-pressed="true"] {
background: rgba(255,77,94,0.15); border-color: #ff4d5e; color: #ff4d5e;
}
.auto-toggle[aria-pressed="true"] .auto-dot {
background: #ff4d5e; box-shadow: 0 0 6px rgba(255,77,94,0.7);
animation: auto-pulse 1.5s ease-in-out infinite;
}
.auto-toggle.closed,
.auto-toggle.closed:hover {
background: #15171f; border-color: #1f2330; color: #5a5f6c;
cursor: not-allowed;
}
.auto-toggle.closed:active { transform: none; }
.auto-toggle.closed .auto-dot {
background: #3a3f4a; box-shadow: none; animation: none;
}
@keyframes auto-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
.section-label {
padding: 22px 18px 8px; margin-top: 10px;
font-size: 12px; font-weight: 600; letter-spacing: 0.05em; color: #8b8f9a;
border-top: 1px solid #2a3142;
}
/* row 바로 다음에 오는 section-label은 분리감 더 강조 — '당일 매매' 끝 → '보유 종목' 시작 경계. */
details.row + .section-label { margin-top: 14px; padding-top: 24px; border-top-color: #3a4154; }
.section-label .count { color: #5a5f6c; font-weight: 400; margin-left: 4px; }
.row { border-bottom: 1px solid #14171f; }
.row > summary {
display: grid;
grid-template-columns: 1fr auto;
gap: 4px 16px;
padding: 8px 18px;
cursor: pointer;
list-style: none;
font-variant-numeric: tabular-nums;
user-select: none;
transition: background 0.1s;
}
/* candle-mini는 grid flow에서 빠지고 카드 row 우측 끝에 absolute 고정 — grid 충돌·implicit row 차단. */
.row > summary:has(.candle-mini) { position: relative; padding-right: 56px; }
.candle-mini { position: absolute; top: 50%; right: 14px; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; pointer-events: none; }
.row > summary::-webkit-details-marker { display: none; }
.row > summary::marker { display: none; }
.row:hover > summary { background: #11141c; }
.row[open] > summary { background: #14171f; }
.left { min-width: 0; display: flex; flex-direction: column; gap: 6px; justify-content: center; }
.right { text-align: right; display: flex; flex-direction: column; gap: 2px; align-items: flex-end; justify-content: center; }
.line1 { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; row-gap: 4px; }
.stock { font-size: 14px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.code { font-size: 11px; color: #5a5f6c; font-weight: 400; flex: none; }
.line2 { display: flex; align-items: center; gap: 8px; font-size: 12px; min-width: 0; }
.price { font-size: 18px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em; display: flex; align-items: center; gap: 6px; }
.right .ref { font-size: 12px; color: #8b8f9a; font-weight: 400; font-variant-numeric: tabular-nums; line-height: 1.2; }
.diff { font-size: 13px; font-weight: 500; color: #8b8f9a; }
.diff .pct { font-size: 13px; margin-left: 4px; opacity: 0.95; }
.row.up .price, .row.up .diff { color: #ff4d5e; }
.row.down .price, .row.down .diff { color: #4a8cf0; }
.day-pct { font-size: 13px; font-weight: 500; }
.day-pct.up { color: #ff4d5e; }
.day-pct.down { color: #4a8cf0; }
.day-pct.neutral { color: #8b8f9a; }
.open-pct { display: inline-block; margin-left: 4px; font-size: 11px; font-weight: 500; font-variant-numeric: tabular-nums; }
.open-pct.up { color: #ff4d5e; }
.open-pct.down { color: #4a8cf0; }
.open-pct.neutral { color: #8b8f9a; }
/* OHLC mini-table — 라벨/가격/시가대비%/전일대비% 4-col grid.
첫 줄에 시가/전일 컬럼 헤더가 한 번만 표시되고, 4행(현재가/시가/고가/저가)이 컬럼 단위로 정렬된다.
`시가` 행의 시가 컬럼은 자기 자신이라 `-` placeholder. */
.ohlc-mini { display: grid; grid-template-columns: max-content max-content 1fr 1fr; column-gap: 14px; row-gap: 4px; margin: 8px 0 4px; width: 100%; font-variant-numeric: tabular-nums; align-items: baseline; }
.ohlc-mini .ohlc-th { font-size: 10px; color: #8b8f9a; font-weight: 500; text-align: right; padding-bottom: 2px; border-bottom: 1px solid #1f2330; }
.ohlc-mini .ohlc-th:first-child, .ohlc-mini .ohlc-th:nth-child(2) { text-align: left; border-bottom: none; }
.ohlc-mini .ohlc-rl { color: #8b8f9a; font-size: 12px; }
.ohlc-mini .ohlc-price { color: #d6d6d6; font-size: 13px; font-weight: 500; text-align: left; }
.ohlc-mini .ohlc-pct { font-size: 12px; font-weight: 500; text-align: right; min-width: 56px; }
.ohlc-mini .ohlc-pct.up { color: #ff4d5e; }
.ohlc-mini .ohlc-pct.down { color: #4a8cf0; }
.ohlc-mini .ohlc-pct.neutral { color: #8b8f9a; }
/* 보유종목 자세히 보기 — KRX/NXT 두 거래소 분리 표시. 3-col grid (라벨/KRX셀/NXT셀). */
.ohlc-mini.ohlc-dual { grid-template-columns: max-content 1fr 1fr; column-gap: 16px; }
.ohlc-mini.ohlc-dual .ohlc-th { text-align: center; border-bottom: 1px solid #1f2330; }
.ohlc-mini.ohlc-dual .ohlc-th:first-child { text-align: left; border-bottom: none; }
.ohlc-mini.ohlc-dual .ohlc-cell { display: flex; flex-direction: row; align-items: baseline; justify-content: flex-end; gap: 8px; }
.ohlc-mini.ohlc-dual .ohlc-cell .ohlc-price { font-size: 13px; text-align: right; }
.ohlc-mini.ohlc-dual .ohlc-cell .ohlc-pct { font-size: 12px; min-width: auto; }
.ohlc-mini.ohlc-dual .ohlc-cell-empty { color: #5a5e69; text-align: right; font-size: 12px; }
/* 비활성 시장 컬럼 회색 처리 — 헤더·셀·가격·등락률 모두 흐리게. */
.ohlc-mini.ohlc-dual .ohlc-th.ohlc-dim { color: #4a4e58; }
.ohlc-mini.ohlc-dual .ohlc-cell.ohlc-dim .ohlc-price { color: #5a5e69; }
.ohlc-mini.ohlc-dual .ohlc-cell.ohlc-dim .ohlc-pct { color: #5a5e69 !important; }
.ohlc-mini.ohlc-dual .ohlc-cell-empty.ohlc-dim { color: #3a3e48; }
/* 미리보기 현재가 왼쪽 출처 마크 — NXT(파랑톤) / KRX(회색톤). 어두운 채도로 보조 정보 강조 X. */
.px-mark { font-size: 8px; font-weight: 600; margin-right: 4px; padding: 1px 3px; border-radius: 2px; background: #14171f; vertical-align: 2px; letter-spacing: 0.3px; }
.px-mark.px-nxt { color: #2d5a8a; }
.px-mark.px-krx { color: #4a4e58; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex: none; box-shadow: 0 0 6px currentColor; }
.dot-1 { background: #f5d423; color: rgba(245,212,35,0.5); }
.dot-2 { background: #f59023; color: rgba(245,144,35,0.55); }
.dot-3 { background: #ff4d5e; color: rgba(255,77,94,0.6); }
/* 매입 총액 등급 뱃지 — 군대 계급 모티프(가로 막대 1~5개). 노랑→진한빨강 그라데이션. */
.rank-badge { display: inline-flex; flex-direction: column; justify-content: center; gap: 1.5px; flex: none; padding: 0 1px; }
.rank-badge .stripe { width: 9px; height: 1.5px; border-radius: 1px; }
.rank-1 .stripe { background: #ffe082; box-shadow: 0 0 4px rgba(255,224,130,0.45); }
.rank-2 .stripe { background: #ffc107; box-shadow: 0 0 4px rgba(255,193,7,0.5); }
.rank-3 .stripe { background: #ff9800; box-shadow: 0 0 5px rgba(255,152,0,0.55); }
.rank-4 .stripe { background: #f57c00; box-shadow: 0 0 5px rgba(245,124,0,0.6); }
.rank-5 .stripe { background: #d32f2f; box-shadow: 0 0 6px rgba(211,47,47,0.65); }
.muted { color: #5a5f6c; font-weight: 400; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 10px; font-weight: 500; line-height: 1.6; font-variant-numeric: tabular-nums; white-space: nowrap; flex: none; letter-spacing: 0.02em; }
.tag-chip { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; line-height: 1.5; color: #d6b34a; background: rgba(214,179,74,0.10); border: 1px solid rgba(214,179,74,0.30); white-space: nowrap; flex: none; letter-spacing: 0.01em; font-family: inherit; pointer-events: none; user-select: none; }
.tag-chip.tag-leader { color: #ff6b78; background: rgba(255,77,94,0.12); border-color: rgba(255,77,94,0.42); font-weight: 600; }
.btn-add-tag { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; line-height: 1.5; color: #5a5f6c; background: transparent; border: 1px dashed rgba(120,124,135,0.30); white-space: nowrap; flex: none; cursor: pointer; font-family: inherit; }
.btn-add-tag:hover { color: #d6b34a; border-color: rgba(214,179,74,0.45); }
.tag-quick-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
.btn-tag-quick { padding: 4px 14px; border-radius: 6px; font-size: 12px; font-weight: 600; color: #b8bcc6; background: #1a1e2a; border: 1px solid #2a2f3c; cursor: pointer; font-family: inherit; letter-spacing: 0.02em; }
.btn-tag-quick:hover { background: #20242f; color: #f0f0f0; }
.btn-tag-quick.active { color: #ff6b78; background: rgba(255,77,94,0.14); border-color: rgba(255,77,94,0.50); }
.btn-tag-quick.active:hover { background: rgba(255,77,94,0.22); }
.badge.watching { background: #1a1e2a; color: #7a8090; }
.badge.held { background: #2a1820; color: #ff8a96; border: 1px solid rgba(255,77,94,0.3); }
.badge.pending-buy { background: #2a1822; color: #ff9fb3; border: 1px solid rgba(255,140,170,0.32); font-weight: 600; padding: 2px 6px; letter-spacing: 0; }
.badge.pending-sell { background: #182030; color: #7fb8ff; border: 1px solid rgba(122,180,255,0.32); font-weight: 600; padding: 2px 6px; letter-spacing: 0; }
dl.pending-detail { margin-top: 8px; padding-top: 8px; border-top: 1px dashed rgba(255,255,255,0.08); grid-template-columns: 92px minmax(0, 1fr); }
.row.mode-held .detail dl.pending-detail { grid-template-columns: 92px minmax(0, 1fr); }
dl.pending-detail dt { font-weight: 600; letter-spacing: 0; white-space: nowrap; }
dl.pending-detail dt.pending-buy-label { color: #ff9fb3; }
dl.pending-detail dt.pending-sell-label { color: #7fb8ff; }
dl.pending-detail dd { line-height: 1.45; }
dl.pending-detail .pending-line-main { font-variant-numeric: tabular-nums; }
dl.pending-detail .pending-line-meta { margin-top: 2px; }
.caret { color: #4a4f5a; font-size: 10px; transition: transform 0.2s; }
.row[open] .caret { transform: rotate(180deg); color: #8b8f9a; }
.detail {
padding: 6px 16px 16px;
background: #0d1018;
border-top: 1px solid #14171f;
font-size: 13px;
}
.detail dl { display: grid; grid-template-columns: 80px 1fr; gap: 6px 14px; margin: 6px 0 14px; }
.row.mode-held .detail dl {
grid-template-columns: 80px minmax(0, 1fr) 80px minmax(0, 1fr);
column-gap: 14px;
row-gap: 6px;
}
.row.mode-held .detail dt.empty,
.row.mode-held .detail dd.empty { visibility: hidden; }
.row.mode-held .detail dl.pl-summary {
grid-template-columns: 92px minmax(0, 1fr);
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #1f2330;
}
.row.mode-held .detail dl.pl-summary dt { white-space: nowrap; }
.row.mode-held .detail dl.pl-summary .pl-divider { grid-column: 1 / -1; border-top: 1px solid #1f2330; margin: 4px 0; height: 0; }
@media (max-width: 640px) {
.row.mode-held .detail dl.pl-summary { grid-template-columns: 92px minmax(0, 1fr); }
}
@media (max-width: 640px) {
.row.mode-held .detail dl {
grid-template-columns: 64px minmax(0, 1fr) 64px minmax(0, 1fr);
column-gap: 8px;
}
}
.detail dt { color: #8b8f9a; font-size: 12px; }
.detail dd { margin: 0; color: #d6d6d6; }
/* 워치리스트·관심종목 카드 본문 — 라벨/값 2-col grid (매수가/목표가/손절가/저장일·매입평단).
OHLC는 별도 `.ohlc-mini` 블록으로 분리. */
.detail .grid2 { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 12px; row-gap: 6px; margin: 6px 0 14px; }
.detail .grid2 > .lbl { color: #8b8f9a; font-size: 12px; white-space: nowrap; align-self: center; }
.detail .grid2 > .val { color: #d6d6d6; font-size: 13px; word-break: break-all; min-width: 0; align-self: center; }
.detail .grid2 > .val.num { font-variant-numeric: tabular-nums; }
.detail .grid2 > .val.muted { color: #8b8f9a; }
@media (max-width: 480px) {
.detail .grid2 { column-gap: 8px; row-gap: 5px; }
.detail .grid2 > .lbl { font-size: 11px; }
.detail .grid2 > .val { font-size: 12px; }
}
.detail .block { margin: 12px 0 0; }
.detail h3 { margin: 0 0 6px; font-size: 11px; color: #8b8f9a; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
.detail ul { margin: 0; padding-left: 18px; line-height: 1.6; color: #c9ccd3; }
.detail li { margin-bottom: 3px; }
.detail .video-link { color: #7aa2ff; text-decoration: none; }
.detail .video-link:hover { text-decoration: underline; }
.detail .block.chart { margin: 8px 0 14px; }
.detail .block.chart img { display: block; width: 100%; max-width: 700px; height: auto; border-radius: 6px; background: #fff; }
.detail .block.chart-svg { margin: 8px 0 14px; min-height: 200px; border-radius: 6px; overflow: hidden; }
.detail .block.chart-svg svg { width: 100%; height: auto; display: block; }
.detail .block.chart-svg .chart-loading { padding: 60px 0; text-align: center; color: #7a8493; font-size: 12px; }
.detail .block.chart-svg .chart-error { padding: 30px 12px; text-align: center; color: #ef4444; font-size: 12px; }
.detail .block.chart-svg .chart-tabs { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.detail .block.chart-svg .chart-tabs button { padding: 5px 12px; background: #1f2937; color: #7a8493; border: 1px solid #2a2f3a; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; font-family: inherit; }
.detail .block.chart-svg .chart-tabs button:hover { color: #cfd5df; border-color: #3f4654; }
.detail .block.chart-svg .chart-tabs button.active { background: rgba(251,191,36,0.12); color: #fbbf24; border-color: #fbbf24; }
.detail .block.chart-svg .chart-panel { display: none; }
.detail .block.chart-svg .chart-panel.active { display: block; }
.detail .block.chart-svg .chart-subtabs { display: flex; gap: 4px; margin-bottom: 6px; flex-wrap: wrap; }
.detail .block.chart-svg .chart-subtabs button { padding: 3px 9px; background: transparent; color: #7a8493; border: 1px solid #2a2f3a; border-radius: 5px; cursor: pointer; font-size: 11px; font-weight: 600; font-family: inherit; }
.detail .block.chart-svg .chart-subtabs button:hover { color: #cfd5df; border-color: #3f4654; }
.detail .block.chart-svg .chart-subtabs button.active { background: rgba(52,211,153,0.10); color: #34d399; border-color: rgba(52,211,153,0.45); }
.detail .block.chart-svg .chart-subpanel { min-height: 200px; }
.actions {
margin-top: 14px; padding-top: 12px;
border-top: 1px solid #1a1e29;
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.actions form { display: inline; margin: 0; }
.actions button {
font: inherit; cursor: pointer;
padding: 6px 14px; border-radius: 6px;
border: 1px solid #2a3142; background: transparent;
color: #b0b4be; font-size: 12px; font-weight: 500;
transition: background 0.12s, border-color 0.12s;
}
.actions button:hover { background: #1c2030; }
.actions button:active { transform: scale(0.97); }
.actions .btn-delete { color: #ff8a96; border-color: rgba(255,77,94,0.32); }
.actions .btn-delete:hover { background: rgba(255,77,94,0.08); }
.actions .btn-alerts-reset { color: #c9ccd3; border-color: rgba(201,204,211,0.28); }
.actions .btn-alerts-reset:hover { background: rgba(201,204,211,0.06); border-color: #6aa9ff; color: #6aa9ff; }
.alert-mark { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; color: #ffd66e; background: rgba(255,214,110,0.10); border: 1px solid rgba(255,214,110,0.32); vertical-align: middle; }
.badge.alerted { color: #ffd66e; background: rgba(255,214,110,0.10); border: 1px solid rgba(255,214,110,0.32); padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.alert-notice { margin: 0 0 10px; padding: 8px 10px; border-radius: 6px; font-size: 12px; color: #ffd66e; background: rgba(255,214,110,0.08); border-left: 3px solid #ffd66e; }
.buy-raw { color: #c9ccd3; margin-bottom: 4px; }
.buy-level { color: #e6e6e6; line-height: 1.5; }
.buy-level + .buy-level { margin-top: 2px; }
.actions .btn-restore { color: #5cd99a; border-color: rgba(46,194,126,0.32); }
.actions .btn-restore:hover { background: rgba(46,194,126,0.08); }
.actions .btn-purge { color: #fff; background: #c43c3c; border-color: #c43c3c; }
.actions .btn-purge:hover { background: #d34a4a; border-color: #d34a4a; }
.actions .btn-edit { color: #c9ccd3; border-color: rgba(201,204,211,0.28); }
.actions .btn-edit:hover { background: rgba(201,204,211,0.06); border-color: #ff4d5e; color: #ff4d5e; }
.actions .small { font-size: 11px; margin-right: auto; }
.section-label.trash-label { color: #6c7180; }
.row.trash > summary { opacity: 0.5; }
.row.trash[open] > summary { opacity: 0.85; }
.empty { padding: 60px 18px; text-align: center; color: #6c7180; }
.add-trigger-wrap { padding: 20px 14px 28px; display: flex; justify-content: center; }
.btn-add-trigger { background: #14171f; color: #e6e6e6; border: 1px dashed #2f3543; border-radius: 10px; padding: 12px 22px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; letter-spacing: 0.02em; }
.btn-add-trigger:hover { border-color: #ff4d5e; color: #ff4d5e; }
.btn-add-trigger:active { transform: translateY(1px); }
.modal { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: max(60px, calc(env(safe-area-inset-top) + 68px)) 20px 20px; }
.modal.modal-top { z-index: 1010; }
.modal.hidden { display: none; }
.modal-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(2px); }
.modal-box { position: relative; background: #0d1018; border: 1px solid #1f2330; border-radius: 12px; padding: 18px 18px 16px; width: 100%; max-width: 480px; max-height: calc(100dvh - env(safe-area-inset-top) - 96px); overflow-y: auto; box-shadow: 0 12px 40px rgba(0,0,0,0.45); }
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.modal-title { font-size: 14px; font-weight: 600; color: #f0f0f0; letter-spacing: 0.02em; }
.modal-close { background: transparent; border: 0; color: #8b8f9a; font-size: 22px; line-height: 1; cursor: pointer; padding: 4px 8px; font-family: inherit; }
.modal-close:hover { color: #f0f0f0; }
.form-feedback { margin-top: 12px; }
.feedback-error { background: #2a1418; border: 1px solid #5a1f28; color: #ff8a95; border-radius: 8px; padding: 10px 12px; font-size: 13px; line-height: 1.5; }
.feedback-title { color: #c9ccd3; font-size: 12px; margin-bottom: 8px; line-height: 1.5; }
.candidate-list { display: flex; flex-direction: column; gap: 6px; }
.candidate-btn { display: flex; align-items: center; justify-content: space-between; gap: 10px; background: #14171f; border: 1px solid #1f2330; color: #e6e6e6; border-radius: 8px; padding: 10px 12px; font-size: 14px; font-family: inherit; cursor: pointer; text-align: left; }
.candidate-btn:hover { border-color: #ff4d5e; }
.candidate-btn:active { transform: translateY(1px); }
.candidate-btn .cand-code { color: #6c7180; font-size: 12px; font-variant-numeric: tabular-nums; }
.modal-actions { display: flex; gap: 8px; margin-top: 14px; }
.modal-actions .info-analyze-link { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 4px; background: rgba(251,191,36,0.08); color: #fbbf24; border: 1px solid rgba(251,191,36,0.35); border-radius: 8px; padding: 10px 14px; font-size: 13px; font-weight: 600; text-decoration: none; font-family: inherit; }
.modal-actions .info-analyze-link:hover { background: rgba(251,191,36,0.16); border-color: #fbbf24; }
.modal-actions .info-analyze-link:active { transform: translateY(1px); }
.modal-actions .btn-cancel { flex: 1; background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 8px; padding: 10px 14px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; }
.modal-actions .btn-add { flex: 2; background: #ff4d5e; color: #fff; border: 0; border-radius: 8px; padding: 10px 16px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; }
.modal-actions .btn-add:active,
.modal-actions .btn-cancel:active { transform: translateY(1px); }
.modal-head .btn-delete { background: transparent; color: #ff8a96; border: 1px solid rgba(255,77,94,0.32); border-radius: 6px; padding: 4px 10px; font-size: 12px; font-weight: 500; cursor: pointer; font-family: inherit; }
.modal-head .btn-delete:hover { background: rgba(255,77,94,0.08); }
.modal-head .btn-delete:active { transform: translateY(1px); }
.add-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 12px; }
.add-form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: #8b8f9a; }
.add-form-grid input { background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 9px 10px; color: #e6e6e6; font-size: 16px; font-family: inherit; }
.add-form-grid input:focus { outline: none; border-color: #ff4d5e; }
@media (max-width: 480px) {
.add-form-grid { grid-template-columns: 1fr; }
.modal { padding: max(56px, calc(env(safe-area-inset-top) + 60px)) 12px 12px; }
.modal-box { padding: 16px 14px 14px; }
}
.loading { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 60px 18px; color: #8b8f9a; font-size: 13px; }
.loading-spin { width: 16px; height: 16px; border: 2px solid #2a2e3a; border-top-color: #ff4d5e; border-radius: 50%; animation: behive-spin 0.8s linear infinite; }
@keyframes behive-spin { to { transform: rotate(360deg); } }
.actions .btn-trades { color: #c9ccd3; border-color: rgba(201,204,211,0.28); }
.actions .btn-trades:hover { background: rgba(201,204,211,0.06); border-color: #6aa9ff; color: #6aa9ff; }
.actions .btn-order { color: #ff8a95; border: 1px solid rgba(255,138,149,0.35); background: rgba(255,138,149,0.06); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; }
.actions .btn-order:hover { background: rgba(255,138,149,0.14); border-color: #ff8a95; }
.actions .btn-order:active { transform: translateY(1px); }
.actions .btn-analyze { color: #fbbf24; border: 1px solid rgba(251,191,36,0.35); background: rgba(251,191,36,0.06); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; gap: 3px; }
.actions .btn-analyze:hover { background: rgba(251,191,36,0.12); border-color: #fbbf24; }
.actions .btn-info { color: #7dd3a8; border: 1px solid rgba(125,211,168,0.35); background: rgba(125,211,168,0.06); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; }
.actions .btn-info:hover { background: rgba(125,211,168,0.14); border-color: #7dd3a8; }
.actions .btn-info:active { transform: translateY(1px); }
.info-body { display: flex; flex-direction: column; gap: 4px; }
.info-grid { display: grid; grid-template-columns: max-content 1fr; gap: 7px 16px; align-items: baseline; }
.info-grid dt { color: #8b8f9a; font-size: 12px; }
.info-grid dd { margin: 0; font-size: 13px; font-variant-numeric: tabular-nums; text-align: right; color: #e8eaed; }
.info-grid dd .up { color: #ff4d5e; } .info-grid dd .down { color: #6aa9ff; }
.info-sec-title { color: #6c7180; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; margin: 12px 0 4px; }
.info-sec-title:first-child { margin-top: 0; }
/* 기업정보 모달 탭 (요약/기업정보·가치/성장성·컨센서스/투자리포트) — 자산탭 sub-tab과 분리된 전용 클래스 */
.info-tabs { display: flex; gap: 2px; margin: 0 0 12px; border-bottom: 1px solid #1f2330; flex-wrap: wrap; }
.info-tabbtn { appearance: none; background: transparent; color: #8b8f9a; border: 0; border-bottom: 2px solid transparent; padding: 8px 11px; font-size: 13px; cursor: pointer; font-family: inherit; font-weight: 600; white-space: nowrap; }
.info-tabbtn:hover { color: #d6d8dd; }
.info-tabbtn.active { color: #f0f0f0; border-bottom-color: #ff4d5e; }
.info-tabpanel[hidden] { display: none; }
.info-q { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0; margin-left: 4px; border-radius: 50%; border: 1px solid rgba(125,211,168,0.4); background: rgba(125,211,168,0.08); color: #7dd3a8; font-size: 10px; font-weight: 700; line-height: 1; cursor: pointer; font-family: inherit; vertical-align: middle; }
.info-q:hover { background: rgba(125,211,168,0.2); border-color: #7dd3a8; }
.info-q:active { transform: translateY(1px); }
.info-desc-box { max-width: 420px; }
.info-desc-body { font-size: 13px; line-height: 1.7; color: #d4d7dd; padding: 4px 2px 8px; }
/* 기업비교 바 */
.info-compare-bar { display: flex; flex-direction: column; gap: 6px; margin: 2px 0 10px; }
.info-search-row { display: flex; gap: 6px; }
.info-search-input { flex: 1; min-width: 0; background: #14171f; color: #e8eaed; border: 1px solid #1f2330; border-radius: 8px; padding: 8px 10px; font-size: 13px; font-family: inherit; }
.info-search-input:focus { outline: none; border-color: #7dd3a8; }
.info-search-btn { flex: 0 0 auto; background: rgba(125,211,168,0.1); color: #7dd3a8; border: 1px solid rgba(125,211,168,0.35); border-radius: 8px; padding: 8px 14px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; }
.info-search-btn:hover { background: rgba(125,211,168,0.18); }
.info-symbols-select { width: 100%; background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 8px; padding: 8px 10px; font-size: 13px; font-family: inherit; color-scheme: dark; }
.info-search-results { display: flex; flex-wrap: wrap; gap: 6px; margin: -4px 0 10px; }
.info-search-results.hidden { display: none; }
.info-search-hit { background: #14171f; color: #e8eaed; border: 1px solid #2a2e3a; border-radius: 8px; padding: 6px 10px; font-size: 12px; cursor: pointer; font-family: inherit; }
.info-search-hit:hover { border-color: #7dd3a8; color: #7dd3a8; }
/* 비교표 */
.info-cmp-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.info-cmp-table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
.info-cmp-table th, .info-cmp-table td { padding: 8px 10px; text-align: right; font-size: 13px; white-space: nowrap; border-bottom: 1px solid #1c2030; }
.info-cmp-table thead th { color: #e8eaed; font-weight: 700; border-bottom: 1px solid #2a2e3a; position: relative; }
.info-cmp-table .info-cmp-label { text-align: left; color: #8b8f9a; font-weight: 500; position: sticky; left: 0; background: #0d1018; z-index: 1; }
.info-cmp-table thead .info-cmp-label { background: #0d1018; }
.info-cmp-name { color: #e8eaed; }
.info-cmp-remove { margin-left: 6px; width: 16px; height: 16px; padding: 0; border-radius: 50%; border: 1px solid rgba(255,138,149,0.4); background: rgba(255,138,149,0.08); color: #ff8a95; font-size: 11px; line-height: 1; cursor: pointer; font-family: inherit; vertical-align: middle; }
.info-cmp-remove:hover { background: rgba(255,138,149,0.2); }
.badge.badge-trade { color: #6c7180; background: transparent; border: 1px solid rgba(120,124,135,0.20); cursor: pointer; font-family: inherit; }
.badge.badge-trade:hover { color: #c9ccd3; border-color: rgba(201,204,211,0.38); background: rgba(201,204,211,0.04); }
.modal-box-wide { max-width: 720px; }
.trade-summary { margin: -6px 0 10px; line-height: 1.5; font-size: 11px; }
.trade-body { overflow-x: auto; }
.trade-table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
.trade-table th, .trade-table td { padding: 7px 8px; border-bottom: 1px solid #1f2330; text-align: right; white-space: nowrap; }
.trade-table th { color: #8b8f9a; font-weight: 600; background: #0d1018; position: sticky; top: 0; }
.trade-table td.l, .trade-table th.l { text-align: left; }
.trade-table tr.seed td { background: rgba(255,255,255,0.025); color: #8b8f9a; }
.trade-table .pl-pos { color: #ff4d5e; }
.trade-table .pl-neg { color: #2c7be5; }
.trade-table .muted { color: #6c7180; }
.trade-table .side-buy { color: #ff4d5e; font-weight: 600; }
.trade-table .side-sell { color: #2c7be5; font-weight: 600; }
.trade-table .trade-stock-col { max-width: 110px; overflow: hidden; text-overflow: ellipsis; }
.trade-table td.l, .trade-table th.l { font-size: 10.5px; }
.trade-pager-wrap { display: block; }
.trade-pager { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 12px; user-select: none; }
.trade-pager .pg-btn { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 8px; padding: 6px 14px; font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit; line-height: 1; }
.trade-pager .pg-btn:hover:not(:disabled) { border-color: #6aa9ff; color: #6aa9ff; }
.trade-pager .pg-btn:active:not(:disabled) { transform: translateY(1px); }
.trade-pager .pg-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.trade-pager .pg-indicator { color: #c9ccd3; font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; min-width: 56px; text-align: center; }
.trade-stock-col { cursor: pointer; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
.trade-list { list-style: none; padding: 0; margin: 0; }
.trade-item { padding: 8px 10px; border-bottom: 1px solid #1f2330; display: flex; flex-direction: column; gap: 3px; font-variant-numeric: tabular-nums; }
.trade-item.seed { background: rgba(255,255,255,0.025); color: #8b8f9a; }
.trade-item.placeholder { visibility: hidden; }
body.modal-open { position: fixed; left: 0; right: 0; width: 100%; overflow: hidden; }
.ti-row1 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: 12px; color: #8b8f9a; }
.ti-meta { display: flex; align-items: baseline; gap: 6px; flex: 1 1 auto; min-width: 0; flex-wrap: wrap; }
.ti-row2 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: 13px; color: #e6e6e6; }
.ti-data { display: flex; align-items: baseline; gap: 6px; flex: 1 1 auto; min-width: 0; flex-wrap: wrap; }
.ti-side { font-weight: 600; }
.ti-side.side-buy { color: #ff4d5e; }
.ti-side.side-sell { color: #2c7be5; }
.ti-pl { font-weight: 600; font-size: 12px; flex: 0 0 auto; white-space: nowrap; }
.ti-pl.pl-pos { color: #ff4d5e; }
.ti-pl.pl-neg { color: #2c7be5; }
.ti-pl.muted { color: #6c7180; }
.ti-stock { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ti-row1 .ti-stock { color: #c9ccd3; font-weight: 500; }
.ti-sep { color: #4a4f5a; }
.ti-label { color: #6c7180; font-size: 11px; font-weight: 500; margin-right: 2px; }
.ti-stock-line { color: #c9ccd3; font-weight: 600; font-size: 12.5px; line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 2px; }
.stock-name-tip {
position: fixed; z-index: 2000; display: none;
max-width: 260px; padding: 7px 11px;
background: #1c2230; color: #f0f0f0;
font-size: 13px; font-weight: 600; line-height: 1.35;
border: 1px solid #2d3340; border-radius: 8px;
box-shadow: 0 6px 20px rgba(0,0,0,0.55);
pointer-events: none; word-break: keep-all; white-space: normal;
}
.stock-name-tip.show { display: block; }
.stock-name-tip::after {
content: ''; position: absolute; left: 50%; bottom: -6px;
width: 0; height: 0; transform: translateX(-50%);
border-left: 6px solid transparent; border-right: 6px solid transparent;
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; }
.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); }
.trade-tabs { display: flex; gap: 2px; margin: 0 0 10px; border-bottom: 1px solid #1f2330; }
.trade-tab { background: transparent; border: 0; padding: 8px 14px; color: #8b8f9a; font-size: 12px; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.trade-tab:hover { color: #c9ccd3; }
.trade-tab[aria-selected="true"] { color: #c9ccd3; border-bottom-color: #ff4d5e; }
.trade-tab-panel[hidden] { display: none; }
.trade-group-sub { font-variant-numeric: tabular-nums; margin: 0 0 8px; font-size: 11px; }
@media (max-width: 600px) {
.row > summary { padding: 8px 14px; }
.stock { font-size: 13px; }
.code { font-size: 10px; }
.price { font-size: 17px; }
.diff { font-size: 12px; }
.diff .pct { font-size: 12px; }
.line2 { gap: 6px; font-size: 11px; }
.badge { font-size: 10px; padding: 2px 7px; }
.detail { padding: 6px 14px 14px; }
.detail dl { grid-template-columns: 72px 1fr; }
.section-label { padding: 18px 14px 6px; }
details.row + .section-label { padding-top: 20px; }
}
/* ── 탭 (URL fragment + html[data-tab] attribute, FOUC 방지) ── */
/* head의 inline script가 body 렌더 전에 location.hash → documentElement.dataset.tab 동기화.
첫 paint 시점에 이미 올바른 탭이 표시되어 새로고침 깜빡임 없음.
섹션은 data-tab 속성으로 매칭 (id 충돌 회피). 탭 anchor 타겟은 없으므로 클릭해도 스크롤 안 일어남. */
nav.tabs {
display: flex; gap: 0;
overflow-x: auto; -webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
nav.tabs::-webkit-scrollbar { display: none; }
nav.tabs a.tab-label {
flex: none;
padding: 12px 16px;
cursor: pointer; user-select: none;
font-size: 13px; font-weight: 500; color: #8b8f9a;
border-bottom: 2px solid transparent;
white-space: nowrap;
transition: color 0.12s, border-color 0.12s;
font-variant-numeric: tabular-nums;
text-decoration: none;
}
nav.tabs a.tab-label:hover { color: #c9ccd3; }
.tab-content { display: none; }
/* 짧은 탭(자산정보 등)도 viewport를 채워 모바일 Safari 주소창 토글이 일관되게 동작하도록.
ticker(fixed bottom) 위치가 다른 탭과 시각적으로 동일해진다. */
section.tab-content { min-height: calc(100dvh - 110px); }
@supports not (height: 100dvh) {
section.tab-content { min-height: calc(100vh - 110px); }
}
/* ── 계좌현황 패널 ── */
.owner-card {
margin: 14px 14px 8px;
padding: 14px 16px;
background: #11141c;
border: 1px solid #1f2330;
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-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;
padding: 4px 8px 4px 0; font-size: 12px; width: 110px; vertical-align: top;
white-space: nowrap;
}
.owner-card table.kpi td { padding: 4px 0; font-size: 13px; color: #e6e6e6; font-weight: 500; text-align: right; }
.owner-card table.kpi td .net { color: #f0f0f0; font-weight: 600; }
.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; }
.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; }
.detail dl .num { font-variant-numeric: tabular-nums; }
.detail .up { color: #ff4d5e; font-weight: 600; }
.detail .down { color: #4a8cf0; font-weight: 600; }
.detail .neutral { color: #c9ccd3; }
.star { color: #d97706; font-size: 12px; flex: none; }
.star.buy { color: #ff4d5e; }
.star.sell { color: #4a8cf0; }
.star.buy + .star.sell { margin-left: 2px; }
.badge.phantom { background: #2a2618; color: #f5b441; border: 1px solid rgba(245,180,65,0.3); }
.row.mode-held .right .ref { color: #8b8f9a; }
.row.mode-phantom .price { font-size: 15px; }
.net-chart {
margin: 12px 14px 0;
padding: 10px 12px 8px;
background: #11141c;
border: 1px solid #1f2330;
border-radius: 10px;
position: relative;
}
.net-chart-head {
display: flex; justify-content: space-between; align-items: center; gap: 8px;
margin-bottom: 4px;
}
.net-chart-title { font-size: 12px; color: #8b8f9a; letter-spacing: -0.01em; }
.net-chart-delta { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
.net-chart-delta.up { color: #ff4d5e; }
.net-chart-delta.down { color: #4a8cf0; }
.net-chart-options {
display: flex; flex-wrap: nowrap; gap: 6px;
margin-top: 8px;
overflow-x: auto; overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin; scrollbar-color: #2a2e36 transparent;
padding-bottom: 2px;
}
.net-chart-options::-webkit-scrollbar { height: 4px; }
.net-chart-options::-webkit-scrollbar-track { background: transparent; }
.net-chart-options::-webkit-scrollbar-thumb { background: #2a2e36; border-radius: 2px; }
/* 단위 토글(일/주/월/연) — 기간 토글 바로 아래에 분리 표시. */
.net-chart-options.nw-units { margin-top: 4px; }
.nw-chip {
appearance: none; background: transparent; color: #c9ccd3;
border: 1px solid #2a2e36; border-radius: 999px;
padding: 5px 12px; font-size: 11px; cursor: pointer;
font-variant-numeric: tabular-nums; line-height: 1;
flex: none; white-space: nowrap;
}
.nw-chip:hover, .nw-chip:active { border-color: #3a3f4a; color: #f0f0f0; }
.nw-chip.active { background: #2a2e36; color: #f0f0f0; border-color: #4a4f5a; font-weight: 600; }
.net-chart-foot-r { display: inline-flex; align-items: center; gap: 6px; }
.nw-nav {
appearance: none; background: transparent; color: #c9ccd3;
border: 1px solid #2a2e36; border-radius: 4px;
width: 22px; height: 22px; padding: 0;
font-size: 9px; line-height: 1; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
}
.nw-nav:hover, .nw-nav:active { color: #f0f0f0; border-color: #3a3f4a; background: #1a1d24; }
.net-chart-svg {
display: block; width: 100%; height: auto;
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
touch-action: pan-y;
cursor: crosshair;
}
.net-chart-svg .grid { stroke: #353b4a; stroke-width: 1; stroke-dasharray: 3 3; }
.net-chart-svg .period-sep { stroke: #4a5266; stroke-width: 1; stroke-dasharray: 2 4; opacity: 0.85; }
.net-chart-svg .zero-line { stroke: #8b93a7; stroke-width: 1.2; stroke-dasharray: 5 3; opacity: 0.9; }
.net-chart-svg .pt-dot { stroke: #0b0d12; stroke-width: 1.2; }
.net-chart-svg .axis { fill: #6b7280; font-size: 14px; font-variant-numeric: tabular-nums; }
.net-chart-svg .area { stroke: none; }
.net-chart-svg .line { fill: none; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; }
.net-chart-svg .line.dashed { stroke-dasharray: 5 4; stroke-opacity: 0.85; }
.net-chart-svg .last-dot { stroke: #0b0d12; stroke-width: 1.5; }
.net-chart-svg .peak-dot { stroke: #0b0d12; stroke-width: 1.5; }
.net-chart-svg .last-dot.live { stroke: #0b0d12; stroke-width: 2; animation: live-pulse 1.6s ease-in-out infinite; }
.net-chart-svg .live-halo { animation: live-halo 1.6s ease-in-out infinite; }
.net-chart-svg .ath-ring { animation: ath-pulse 1.8s ease-in-out infinite; }
.net-chart-svg .ath-label { paint-order: stroke; stroke: #0b0d12; stroke-width: 2.5; stroke-linejoin: round; }
@keyframes live-pulse {
0%, 100% { r: 3.6; }
50% { r: 4.4; }
}
@keyframes live-halo {
0%, 100% { fill-opacity: 0.18; r: 6; }
50% { fill-opacity: 0.32; r: 8; }
}
@keyframes ath-pulse {
0%, 100% { r: 7; stroke-opacity: 0.95; }
50% { r: 8.5; stroke-opacity: 0.55; }
}
.net-chart-svg .nw-hover-line { stroke: #d6d8dd; stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.7; }
.net-chart-svg .nw-hover-dot { fill: #f0f0f0; stroke: #0b0d12; stroke-width: 1.5; }
.net-chart-tip {
position: absolute;
top: 30px;
pointer-events: none;
background: rgba(20, 22, 28, 0.94);
border: 1px solid #2a2e36;
border-radius: 6px;
padding: 6px 24px 6px 8px;
font-size: 11px;
color: #d6d8dd;
opacity: 0;
transition: opacity 0.12s ease;
white-space: nowrap;
z-index: 5;
min-width: 140px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
}
.tip-close {
position: absolute; top: 2px; right: 2px;
appearance: none; background: transparent; border: none;
color: #6b7280; font-size: 11px; line-height: 1;
width: 18px; height: 18px; padding: 0; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 3px;
pointer-events: auto;
}
.tip-close:hover, .tip-close:active { color: #f0f0f0; background: rgba(255,255,255,0.08); }
.net-chart-tip .tip-date { font-weight: 600; color: #f0f0f0; font-variant-numeric: tabular-nums; margin-bottom: 4px; font-size: 11px; }
.net-chart-tip .tip-row { display: flex; justify-content: space-between; gap: 12px; font-variant-numeric: tabular-nums; }
.net-chart-tip .tip-row[hidden] { display: none; }
.net-chart-tip .tip-row + .tip-row { margin-top: 2px; }
.net-chart-tip .tip-row .muted { color: #6b7280; font-weight: 400; }
.net-chart-tip .tip-row b { font-weight: 600; }
.net-chart-tip .up { color: #ff4d5e; }
.net-chart-tip .down { color: #4a8cf0; }
.net-chart-tip .neutral { color: #c9ccd3; }
.net-chart-foot {
display: flex; justify-content: space-between; align-items: baseline;
margin-top: 2px; font-size: 11px;
}
.net-chart-foot .muted { color: #6b7280; }
.net-chart-foot .net-chart-last { color: #f0f0f0; font-weight: 600; font-variant-numeric: tabular-nums; }
.chart-empty {
margin: 12px 14px 0;
padding: 12px;
background: #11141c;
border: 1px solid #1f2330;
border-radius: 10px;
font-size: 12px; color: #8b8f9a; text-align: center;
}
/* 시장 상황 카드 — 자산정보 탭의 시장정보 sub-tab 내부 (ADR + 투자자별 매매). */
.market-card {
margin: 14px 14px 8px;
padding: 14px 16px;
background: #11141c;
border: 1px solid #1f2330;
border-radius: 10px;
}
.market-card-error {
font-size: 12px; color: #8b8f9a; text-align: center; padding: 12px;
}
.market-card-title {
font-size: 15px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em;
margin-bottom: 10px;
display: flex; align-items: baseline; gap: 8px;
}
.market-card .market-date {
font-size: 11px; color: #5a5f6c; font-weight: 400; font-variant-numeric: tabular-nums;
}
.market-refresh-btn {
margin-left: auto; padding: 2px 9px; background: #1a1d24; color: #c9ccd3; border: 1px solid #2a2f3a;
border-radius: 999px; cursor: pointer; font-family: inherit; font-size: 13px; line-height: 1;
font-weight: 600; transition: background 0.15s, color 0.15s;
}
.market-refresh-btn:hover, .market-refresh-btn:active { background: #2a2f3a; color: #f0f0f0; }
.market-refresh-btn.spinning { animation: market-spin 0.6s linear infinite; }
@keyframes market-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.market-idx-info {
margin-left: 6px; padding: 0 5px; background: transparent; color: #6b7280; border: 1px solid #2a2f3a;
border-radius: 50%; font-family: inherit; font-size: 10px; line-height: 16px; cursor: pointer; vertical-align: middle;
font-weight: 600; transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.market-idx-info:hover { color: #ff8a95; border-color: #ff8a95; background: rgba(255,138,149,0.08); }
.idx-info-body {
padding: 14px 16px; font-size: 14px; color: #c9ccd3; line-height: 1.6; letter-spacing: -0.01em;
}
.idx-info-stats {
display: flex; align-items: baseline; gap: 12px; padding-bottom: 10px;
margin-bottom: 10px; border-bottom: 1px solid #1f2330; font-variant-numeric: tabular-nums;
}
.idx-info-price { font-size: 22px; font-weight: 700; color: #f0f0f0; }
.idx-info-change { font-size: 14px; font-weight: 600; }
.idx-info-change.up { color: #ff4d5e; }
.idx-info-change.down { color: #4a8cf0; }
.idx-info-change.flat { color: #8b8f9a; }
.idx-info-desc {
font-size: 13px; color: #c9ccd3; white-space: pre-line; line-height: 1.7;
}
.idx-info-chart {
margin: 0 0 12px; padding: 8px 10px; background: #0e1117; border: 1px solid #1f2330; border-radius: 8px;
position: relative;
}
.idx-chart-svg { display: block; width: 100%; height: auto; cursor: crosshair; }
.idx-chart-svg .idx-axis { fill: #6b7280; font-size: 9px; font-variant-numeric: tabular-nums; }
.idx-chart-svg .idx-grid { stroke: #1f2330; stroke-width: 1; stroke-dasharray: 2 3; }
.idx-chart-svg .idx-hover-line { stroke: #d6d8dd; stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.75; }
.idx-chart-svg .idx-hover-dot { stroke: #0b0d12; stroke-width: 1.5; }
.idx-chart-tip {
position: absolute; top: 6px; right: 14px; background: #14171f; border: 1px solid #2a2f3a;
border-radius: 6px; padding: 4px 8px; font-size: 11px; color: #f0f0f0;
display: flex; gap: 8px; align-items: center; font-variant-numeric: tabular-nums;
}
.idx-chart-tip[hidden] { display: none; }
.idx-chart-tip .tip-d { color: #8b8f9a; pointer-events: none; }
.idx-chart-tip .tip-v { font-weight: 600; pointer-events: none; }
.idx-chart-tip .tip-close {
position: static; width: auto; height: auto;
background: transparent; color: #8b8f9a; border: 0; padding: 0 4px; cursor: pointer;
font-family: inherit; font-size: 14px; line-height: 1; margin-left: 4px;
display: inline-flex; align-items: center;
}
.idx-chart-tip .tip-close:hover { color: #ff8a95; background: transparent; }
.idx-chart-loading, .idx-chart-err {
padding: 24px 0; text-align: center; font-size: 12px; color: #8b8f9a;
}
.idx-chart-err { color: #ff8a95; }
.idx-chart-foot {
display: flex; justify-content: space-between; margin-top: 6px;
font-size: 11px; color: #5a5f6c; font-variant-numeric: tabular-nums;
}
.market-section { margin-top: 10px; }
.market-section:first-of-type { margin-top: 0; }
.market-section-head {
display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
margin-bottom: 4px;
}
.market-section-label { font-size: 12px; color: #d6d8dd; font-weight: 600; }
.market-section-hint { font-size: 10px; color: #5a5f6c; }
table.market-table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
table.market-table th {
text-align: left; font-weight: 400; color: #8b8f9a;
padding: 4px 8px 4px 0; font-size: 11px; vertical-align: middle;
}
table.market-table td { padding: 4px 0; font-size: 13px; color: #e6e6e6; font-weight: 500; text-align: right; }
table.market-table thead th { text-align: right; font-size: 10px; color: #6b7280; padding-bottom: 2px; }
table.market-table thead th:first-child { text-align: left; }
table.market-table .up { color: #ff4d5e; font-weight: 600; }
table.market-table .down { color: #4a8cf0; font-weight: 600; }
table.market-table .flat { color: #c9ccd3; }
table.market-table .neutral { color: #8b8f9a; }
table.market-adr th { width: 64px; }
table.market-adr td.adr-val { width: 88px; }
table.market-adr td.adr-val b { font-size: 14px; }
table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
.adr-tag {
display: inline-block; padding: 1px 6px; border-radius: 999px;
font-size: 9px; font-weight: 600; letter-spacing: 0.04em;
margin-left: 4px; vertical-align: middle;
}
.adr-tag.up { background: rgba(255,77,94,0.16); color: #ff8a96; border: 1px solid rgba(255,77,94,0.3); }
.adr-tag.down { background: rgba(74,140,240,0.16); color: #8db4f4; border: 1px solid rgba(74,140,240,0.3); }
.adr-tag.flat { background: #1a1d24; color: #8b8f9a; border: 1px solid #2a2e36; }
.adr-sub { margin-top: 2px; font-size: 10px; line-height: 1.1; color: #8b8f9a; }
@media (max-width: 600px) {
.market-card { margin: 10px 10px 6px; padding: 12px 14px; }
table.market-table td { font-size: 12px; }
table.market-adr th { width: 56px; font-size: 11px; }
table.market-adr td.adr-breakdown { font-size: 10px; }
}
/* ADR 추세 카드 — 코스피·코스닥 한 SVG 안에 통합. 시장정보 sub-tab 내부. */
.adr-trend-card .market-card-sub {
font-size: 11px; color: #5a5f6c; font-weight: 400; margin-left: 4px;
}
.adr-trend-card .market-section-hint { margin-bottom: 8px; }
.adr-combo-meta {
display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap;
margin: 8px 0 4px;
font-variant-numeric: tabular-nums;
}
.adr-legend {
display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; color: #d6d8dd; font-weight: 500;
}
.adr-legend-swatch {
display: inline-block; width: 10px; height: 10px; border-radius: 2px;
}
.adr-combo-range { margin-left: auto; }
.adr-trend-row {
position: relative;
margin-top: 6px;
touch-action: pan-y;
}
.adr-trend-svg {
width: 100%; height: 100px; display: block;
font-variant-numeric: tabular-nums;
}
/* 호버 인디케이터 — 세로선 1 + 시장당 점. net-chart nw-hover 패턴 확장. */
.adr-trend-svg .adr-hover { transition: opacity 0.1s; }
.adr-trend-svg .adr-hover-line { stroke: #d6d8dd; stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.7; }
.adr-trend-svg .adr-hover-dot { stroke-width: 1.2; }
.adr-trend-tip {
position: absolute;
background: #11141c;
border: 1px solid #2a2e36;
border-radius: 6px;
padding: 5px 8px;
font-size: 11px;
color: #d6d8dd;
pointer-events: none;
opacity: 0;
transition: opacity 0.1s;
z-index: 5;
white-space: nowrap;
min-width: 110px;
}
.adr-trend-tip .tip-d { font-weight: 600; color: #f0f0f0; font-variant-numeric: tabular-nums; margin-bottom: 2px; }
.adr-trend-tip .tip-line {
display: flex; justify-content: space-between; gap: 10px; align-items: baseline;
font-variant-numeric: tabular-nums;
}
.adr-trend-tip .tip-line[hidden] { display: none; }
.adr-trend-tip .tip-mk { font-size: 10px; color: #8b8f9a; }
.adr-trend-tip .tip-kospi .tip-mk { color: #fbbf24; }
.adr-trend-tip .tip-kosdaq .tip-mk { color: #34d399; }
.adr-trend-tip .tip-v { font-weight: 700; font-variant-numeric: tabular-nums; }
.adr-trend-tip .tip-v.up { color: #ff8a96; }
.adr-trend-tip .tip-v.down { color: #8db4f4; }
.adr-trend-tip .tip-v.flat { color: #d6d8dd; }
.adr-trend-tip .tip-v.live::after { content: " *"; color: #fbbf24; font-weight: 600; }
.adr-trend-empty {
display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
padding: 8px 0;
}
.adr-trend-controls {
display: flex; gap: 6px; margin: 8px 0 4px; flex-wrap: wrap;
}
.adr-range-btn {
appearance: none; -webkit-appearance: none;
background: #11141c; color: #c9ccd3;
border: 1px solid #2a2e36; border-radius: 8px;
padding: 5px 11px; font-size: 11px; font-weight: 500; font-family: inherit;
cursor: pointer;
font-variant-numeric: tabular-nums;
}
.adr-range-btn:hover, .adr-range-btn:active { background: #1a1d26; color: #f0f0f0; }
.adr-range-btn.active {
background: rgba(251,191,36,0.12);
color: #fbbf24;
border-color: rgba(251,191,36,0.4);
}
.adr-range-btn:focus-visible { outline: 1px solid #4a4f5a; outline-offset: 1px; }
.adr-live-badge {
display: inline-block; margin-left: 6px;
font-size: 9px; font-weight: 600; letter-spacing: 0.04em;
color: #fbbf24;
}
/* 자산정보 탭 내부 sub-tab — 자산보기/차트보기. 상단 배치는 밑줄 강조, 하단 배치는 일반 버튼 형태. */
.sub-tabs { display: flex; gap: 4px; margin: 8px 14px 0; border-bottom: 1px solid #1f2330; }
.sub-tab { appearance: none; background: transparent; color: #8b8f9a; border: 0; border-bottom: 2px solid transparent; padding: 8px 14px; font-size: 13px; cursor: pointer; font-family: inherit; font-weight: 500; }
.sub-tab:hover, .sub-tab:active { color: #d6d8dd; }
.sub-tab.active { color: #f0f0f0; border-bottom-color: #ff4d5e; }
.sub-tab-panel[hidden] { display: none; }
/* 하단 배치 — 탭 강조선 제거하고 둥근 버튼 형태. */
.sub-tabs.sub-tabs-bottom { margin: 14px 14px; border: 0; padding: 0; gap: 8px; justify-content: center; }
.sub-tabs.sub-tabs-bottom .sub-tab {
border: 1px solid #2a2e36;
border-radius: 8px;
background: #11141c;
color: #c9ccd3;
padding: 8px 18px;
}
.sub-tabs.sub-tabs-bottom .sub-tab:hover, .sub-tabs.sub-tabs-bottom .sub-tab:active { background: #1a1d26; color: #f0f0f0; }
.sub-tabs.sub-tabs-bottom .sub-tab.active {
background: #2a1820;
color: #ff8a96;
border-color: rgba(255,77,94,0.32);
}
/* 합산 / 각계좌별도 단일 토글 버튼 — 상단 자동토글 옆 1곳에만 배치. 클릭마다 두 상태 swap.
data-account-view-state 속성으로 현재 상태 표시. 기본(합산)은 중성색, 각계좌별도일 때 강조. */
.account-view-toggle-btn {
flex: none; display: inline-flex; align-items: center; justify-content: center;
min-width: 36px; height: 36px; padding: 0 10px;
background: #1c2230; border: 1px solid #2a3142; color: #e6e6e6;
border-radius: 8px; font-size: 17px; line-height: 1; cursor: pointer;
font-family: inherit; user-select: none;
transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s;
}
.account-view-toggle-btn:hover { background: #232a3b; }
.account-view-toggle-btn:active { transform: scale(0.95); }
.account-view-toggle-btn[data-account-view-state="by-account"] {
background: rgba(255,77,94,0.15); border-color: #ff4d5e; color: #ff4d5e;
}
.account-view-pane[hidden] { display: none; }
.holdings-toggle-wrap { margin-top: 4px; }
/* 차트 컨트롤 — 자산정보 탭 상단의 공통 기간·단위 select 묶음. */
.chart-controls { display: flex; gap: 10px; margin: 10px 14px 4px; flex-wrap: wrap; }
.chart-controls .cc-field { display: flex; align-items: center; gap: 6px; }
.chart-controls .cc-label { color: #8b8f9a; font-size: 11px; }
.chart-controls .nw-select {
appearance: none; -webkit-appearance: none;
background: #11141c url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><path fill='%238b8f9a' d='M2 4l4 4 4-4'/></svg>") no-repeat right 8px center;
background-size: 10px;
color: #d6d8dd; border: 1px solid #2a2e36; border-radius: 8px;
padding: 6px 26px 6px 10px; font-size: 12px; cursor: pointer; font-family: inherit;
min-width: 96px;
}
.chart-controls .nw-select:focus { outline: 1px solid #4a4f5a; outline-offset: 0; }
@media (max-width: 600px) {
nav.tabs .tab-label { padding: 10px 12px; font-size: 12px; }
.owner-card { margin: 10px 10px 6px; padding: 12px 14px; }
.owner-card table.kpi th { width: auto; font-size: 11px; }
.owner-card table.kpi td { font-size: 12px; }
.cash-list { margin: 4px 10px 10px; font-size: 11px; }
.net-chart { margin: 10px 10px 0; padding: 8px 10px 6px; }
/* 모바일은 viewBox(800) > viewport 라 scale 0.5 정도 — viewBox unit 폰트를 크게 잡아 화면에서 적당히 보이게. */
.net-chart-svg .axis { font-size: 22px; }
}
/* 주문 모달 (order-modal) — 거래내역 보기용 trade-modal과 별개. 모달 박스 자체 스크롤 X (호가창만 스크롤) */
.order-box { max-width: 560px; overflow-y: hidden; display: flex; flex-direction: column; }
.order-box > [data-order-step="1"] { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.order-symbol-select { background: #14171f; color: #f0f0f0; border: 1px solid #1f2330; border-radius: 6px; padding: 7px 9px; font-size: 14px; font-weight: 600; font-family: inherit; width: 100%; max-width: 360px; cursor: pointer; color-scheme: dark; }
.order-symbol-select:focus { outline: none; border-color: #ff4d5e; }
.order-symbol-select option { background: #14171f; color: #f0f0f0; }
/* native select 화살표(chevron) — 다크 테마로 밝은 색 강제 */
.order-inputs select, .open-orders-box select { color-scheme: dark; }
.order-meta { display: flex; gap: 8px; align-items: baseline; margin-bottom: 12px; flex-wrap: nowrap; }
.order-meta .cur-price { font-size: clamp(14px, 4.5vw, 22px); font-weight: 700; color: #f0f0f0; font-variant-numeric: tabular-nums; white-space: nowrap; flex-shrink: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.order-meta .change { font-size: clamp(10px, 2.8vw, 13px); font-variant-numeric: tabular-nums; white-space: nowrap; }
.order-meta .change.up { color: #ff5b69; }
.order-meta .change.down { color: #5b9bff; }
.order-market-phase { font-size: 10px; padding: 2px 7px; border-radius: 4px; margin-left: auto; font-variant-numeric: tabular-nums; }
.order-market-phase.regular { background: rgba(31,90,40,0.35); color: #8aff95; border: 1px solid rgba(31,90,40,0.6); }
.order-market-phase.nxt { background: rgba(86,42,138,0.35); color: #d3a7ff; border: 1px solid rgba(86,42,138,0.6); }
.order-market-phase.closed, .order-market-phase.holiday, .order-market-phase.weekend { background: rgba(90,31,40,0.35); color: #ff8a95; border: 1px solid rgba(90,31,40,0.6); }
.order-side-toggle { display: flex; gap: 0; margin-bottom: 12px; border-radius: 8px; overflow: hidden; border: 1px solid #1f2330; }
.order-side-toggle button { flex: 1; padding: 10px 12px; background: #14171f; color: #c9ccd3; border: 0; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border-right: 1px solid #1f2330; }
.order-side-toggle button:last-child { border-right: 0; }
.order-side-toggle button.active[data-side="BUY"] { background: #c9303e; color: #fff; }
.order-side-toggle button.active[data-side="SELL"] { background: #2a5fb3; color: #fff; }
.order-side-toggle button.info-tab { color: #8db4ff; max-width: 100px; }
.order-side-toggle button.info-tab:hover { background: #1f2840; color: #c9e0ff; }
/* 좌우 2-컬럼 강제 (호가창 좁게, 인풋 영역 넓게). flex로 grid 안 남은 공간 채워 stretch */
.order-body { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.25fr); gap: 10px; align-items: stretch; flex: 1; min-height: 0; }
/* 호가창만 폰트 ~70% 압축, 별도 스크롤. height 100%로 인풋 영역과 동일 stretch */
.orderbook { display: flex; flex-direction: column; justify-content: center; border: 1px solid #1f2330; border-radius: 6px; overflow-y: auto; overflow-x: hidden; height: 100%; min-height: 0; scrollbar-width: thin; }
.orderbook::-webkit-scrollbar { width: 4px; }
.orderbook::-webkit-scrollbar-thumb { background: #2a2e3a; border-radius: 2px; }
.orderbook .ob-row { display: grid; grid-template-columns: 1fr 1fr; padding: 1px 5px; font-size: 7.5px; line-height: 1.35; font-variant-numeric: tabular-nums; cursor: pointer; border-bottom: 1px solid #15171e; transition: box-shadow 0.08s; }
.orderbook .ob-row:last-of-type { border-bottom: 0; }
.orderbook .ob-row.ask { background: linear-gradient(to left, rgba(201,48,62,0.16), transparent 60%); color: #ff8a95; }
.orderbook .ob-row.bid { background: linear-gradient(to left, rgba(42,95,179,0.16), transparent 60%); color: #8db4ff; }
.orderbook .ob-row:hover { background: #1c1f29; }
.orderbook .ob-row.selected { box-shadow: inset 0 0 0 1.5px #ffcc33; color: #fff !important; font-weight: 700; background-color: rgba(255,204,51,0.18) !important; }
.orderbook .ob-divider { padding: 2px 5px; font-size: 8.5px; font-weight: 700; color: #f0f0f0; background: #161922; text-align: center; border-top: 1px solid #1f2330; border-bottom: 1px solid #1f2330; font-variant-numeric: tabular-nums; }
.orderbook .ob-row .price { text-align: left; }
.orderbook .ob-row .qty { text-align: right; opacity: 0.8; font-size: 7px; }
.price-tick-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-top: 4px; }
.price-tick-buttons button { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 5px; padding: 5px 4px; font-size: 11px; cursor: pointer; font-family: inherit; }
.price-tick-buttons button:hover { border-color: #ff4d5e; color: #f0f0f0; }
.price-tick-buttons button:active { transform: translateY(1px); }
.qty-step-buttons { display: grid; grid-template-columns: repeat(5, 1fr); gap: 3px; margin-top: 4px; }
.qty-step-buttons button { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 5px; padding: 5px 2px; font-size: 10px; cursor: pointer; font-family: inherit; }
.qty-step-buttons button:hover { border-color: #ff4d5e; color: #f0f0f0; }
.qty-step-buttons button:active { transform: translateY(1px); }
.qty-step-buttons button[data-qty-step="100%"],
.qty-step-buttons button[data-qty-step="max"] { background: #2a1f24; color: #ffcc77; }
.qty-step-buttons button[data-qty-step="clear"] { background: #1d1418; color: #ff8a95; }
.order-inputs input:disabled, .order-inputs select:disabled { opacity: 0.5; cursor: not-allowed; }
.order-inputs { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
.order-inputs .order-actions { margin-top: auto; }
.order-inputs label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: #8b8f9a; }
.order-inputs label.inline-row { flex-direction: row; align-items: center; gap: 8px; }
.order-inputs label.inline-row > select { flex: 1; min-width: 0; }
.order-inputs input, .order-inputs select { background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 9px 10px; color: #e6e6e6; font-size: 16px; font-family: inherit; }
.order-inputs input:focus, .order-inputs select:focus { outline: none; border-color: #ff4d5e; }
.order-inputs .max-qty { font-size: 10.5px; color: #8b8f9a; font-weight: 400; margin-left: 4px; }
.order-info { font-size: clamp(9.5px, 2.6vw, 12px); color: #c9ccd3; padding: 8px 10px; background: #11141b; border: 1px solid #1f2330; border-radius: 6px; line-height: 1.55; word-break: keep-all; }
.order-info b { white-space: nowrap; }
.order-info .pl-up { color: #ff8a95; white-space: nowrap; }
.order-info .pl-down { color: #8db4ff; white-space: nowrap; }
.order-info b.qty-over { color: #ff8a95; }
input[data-order-qty].qty-over { color: #ff8a95; }
.order-modal-msg { padding: 12px; margin-bottom: 12px; border-radius: 8px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; font-variant-numeric: tabular-nums; }
.order-modal-msg.error { background: #2a1418; border: 1px solid #5a1f28; color: #ff8a95; }
.order-modal-msg.success { background: #142a18; border: 1px solid #1f5a28; color: #8aff95; }
.order-modal-msg.info { background: #14171f; border: 1px solid #1f2330; color: #c9ccd3; }
.order-modal-msg.hidden { display: none; }
.order-pin-row { display: flex; flex-direction: column; gap: 6px; margin: 12px 0; }
.order-pin-row label { font-size: 11px; color: #8b8f9a; }
.order-pin-row input { background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 12px 14px; color: #e6e6e6; font-size: 22px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.2em; text-align: center; }
.order-pin-row input:focus { outline: none; border-color: #ff4d5e; }
.order-countdown { font-size: 13px; color: #8b8f9a; margin-top: 6px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; transition: color 0.15s; }
.order-countdown.warn { color: #ffcc77; }
.order-countdown.danger { color: #ff5b69; }
.order-actions { display: flex; gap: 8px; margin-top: 14px; }
.order-actions button { flex: 1; padding: 11px 14px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border: 0; }
.order-actions .btn-cancel { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; }
.order-actions .btn-confirm { background: #c9303e; color: #fff; }
.order-actions .btn-confirm[data-side="SELL"] { background: #2a5fb3; }
.order-actions button:active { transform: translateY(1px); }
.order-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
.sub-tab-order-btn { margin-left: auto; background: #2a1418; color: #ff8a95; border: 1px solid rgba(255,138,149,0.45); border-radius: 6px; padding: 6px 14px; font-size: 12px; font-weight: 700; cursor: pointer; font-family: inherit; }
.sub-tab-order-btn:hover { background: rgba(255,138,149,0.16); border-color: #ff8a95; color: #fff; }
.sub-tab-order-btn:active { transform: translateY(1px); }
.pin-box { max-width: 400px; }
.pin-box > .hidden { display: none; }
.open-orders-box { max-width: 560px; }
.open-orders-list { display: flex; flex-direction: column; gap: 8px; max-height: 50dvh; overflow-y: auto; margin-bottom: 12px; }
.open-orders-list .empty { padding: 32px 12px; text-align: center; color: #8b8f9a; font-size: 12px; }
.open-order-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 10px 12px; background: #14171f; border: 1px solid #1f2330; border-radius: 8px; align-items: center; }
.open-order-row.active-card { border-color: rgba(255,204,51,0.45); background: rgba(255,204,51,0.06); }
.open-order-row .row-info { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.open-order-row .row-line1 { display: flex; gap: 6px; align-items: baseline; font-size: 13px; font-weight: 600; color: #f0f0f0; }
.open-order-row .row-line1 .badge-side { font-size: 10px; padding: 1px 6px; border-radius: 3px; font-weight: 700; }
.open-order-row .row-line1 .badge-side.buy { background: #c9303e; color: #fff; }
.open-order-row .row-line1 .badge-side.sell { background: #2a5fb3; color: #fff; }
.open-order-row .row-line1 .badge-side.pin { background: #ffcc33; color: #14171f; }
.open-order-row .row-line2 { font-size: 11px; color: #8b8f9a; font-variant-numeric: tabular-nums; }
.open-order-row .row-action button { background: #2a1418; color: #ff8a95; border: 1px solid rgba(255,138,149,0.35); border-radius: 6px; padding: 6px 12px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; }
.open-order-row .row-action button:hover { background: rgba(255,138,149,0.14); border-color: #ff8a95; }
.open-order-row .row-action button:disabled { opacity: 0.5; cursor: not-allowed; }
.open-order-row.active-card .row-action button { background: rgba(255,204,51,0.12); color: #ffcc77; border-color: rgba(255,204,51,0.45); }
.open-order-row.active-card .row-action button:hover { background: rgba(255,204,51,0.25); }
/* 매매 거부·검증 에러 토스트 — 화면 하단, 자동 사라짐 */
.order-toast { position: fixed; left: 50%; bottom: 80px; transform: translateX(-50%); z-index: 2000; background: #2a1418; border: 1px solid #5a1f28; color: #ff8a95; padding: 12px 18px; border-radius: 8px; font-size: 13px; max-width: 90vw; box-shadow: 0 6px 20px rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.2s; pointer-events: none; white-space: pre-wrap; line-height: 1.55; font-variant-numeric: tabular-nums; }
.order-toast.show { opacity: 1; }
.order-toast.success { background: #142a18; border-color: #1f5a28; color: #8aff95; }
.order-toast.info { background: #14171f; border-color: #1f2330; color: #c9ccd3; }
'''
OWNER_TAB_IDS = {'본인': 'self', '가희': 'gahee'}
def _owner_tab_id(owner: str) -> str:
return OWNER_TAB_IDS.get(owner, 'owner-' + owner.encode('ascii', 'replace').decode().replace('?', 'x'))
def _owner_full_title(owner: str) -> str:
"""stock_portfolio_report.OWNER_LABELS를 단일 진실 출처로 사용 (drift 방지)."""
from stock_portfolio_report import OWNER_LABELS
return OWNER_LABELS.get(owner, f'{owner} 자산')
def _render_chart_controls(selected_days: int, selected_unit: str, selected_mode: str = NET_WORTH_CHART_MODE) -> str:
"""차트용 공통 컨트롤 — 기간 / 단위 / 모드(순자산·손익누적·기간손익) select 3개.
자산정보 탭 상단에 1세트만 노출, 본인·가희 차트 둘 다 동시 컨트롤.
의미없는 단위(이번주+월별 등)는 disabled."""
allowed_units = NET_WORTH_ALLOWED_UNITS_BY_DAYS.get(
selected_days, tuple(u for _, u in NET_WORTH_UNIT_PRESETS)
)
range_opts = ''.join(
f'<option value="{d}"{" selected" if d == selected_days else ""}>{html.escape(lbl)}</option>'
for lbl, d in NET_WORTH_CHART_PRESETS
)
unit_opts = ''.join(
f'<option value="{u}"'
f'{" selected" if u == selected_unit else ""}'
f'{" disabled" if u not in allowed_units else ""}>'
f'{html.escape(lbl)}</option>'
for lbl, u in NET_WORTH_UNIT_PRESETS
)
mode_opts = ''.join(
f'<option value="{m}"{" selected" if m == selected_mode else ""}>{html.escape(lbl)}</option>'
for lbl, m in NET_WORTH_MODE_PRESETS
)
return (
'<div class="chart-controls">'
f'<label class="cc-field"><span class="cc-label">기간</span>'
f'<select class="nw-select" data-nw-range>{range_opts}</select></label>'
# 단위·모드 한 cc-field 묶음 — 좁은 화면 줄바꿈 방지 + 시각적 인접 배치.
f'<label class="cc-field cc-unit"><span class="cc-label">단위</span>'
f'<select class="nw-select" data-nw-unit>{unit_opts}</select>'
f'<select class="nw-select nw-mode-inline" data-nw-mode aria-label="차트 모드">{mode_opts}</select></label>'
'</div>'
)
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 (
'<div class="market-section">'
+ '<div class="market-section-hint">시장 지표 미수신</div>'
+ '</div>'
)
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 = '<div class="adr-sub muted small">20일 이평</div>'
else:
adr_str = ''
sub_html = f'<div class="adr-sub muted small">누적 중 {count}/{ADR_MA_WINDOW}</div>'
tag_html = f' <span class="adr-tag {adr_cls}">{adr_tag}</span>' if adr_tag else ''
body_rows.append(
f'<tr>'
f'<th>{html.escape(r["label"])}</th>'
f'<td class="adr-val"><b class="{adr_cls}">{adr_str}</b>{tag_html}{sub_html}</td>'
f'<td class="adr-breakdown"><span class="up">{r["rise"]:,}↑</span> / <span class="down">{r["fall"]:,}↓</span> / <span class="neutral">{r["steady"]:,}→</span></td>'
f'</tr>'
)
return (
'<div class="market-section">'
+ f'<table class="market-table market-adr"><tbody>{"".join(body_rows)}</tbody></table>'
+ '</div>'
)
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 '<div class="market-card market-card-error">시장 지표를 불러올 수 없습니다.</div>'
# 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'<button type="button" class="market-idx-info" data-idx-info '
f'data-idx-label="{html.escape(label, quote=True)}" '
f'data-idx-desc="{html.escape(desc, quote=True)}" '
f'data-idx-price="{html.escape(price, quote=True)}" '
f'data-idx-change="{html.escape(change, quote=True)}" '
f'data-idx-pct="{html.escape(pct, quote=True)}" '
f'data-idx-direction="{direction}" '
f'data-idx-symbol="{html.escape(symbol_attr, quote=True)}" '
f'aria-label="설명">ⓘ</button>'
) if desc else ''
index_rows.append(
f'<tr class="market-idx-row">'
f'<th>{html.escape(label)}{info_btn}</th>'
f'<td class="market-idx-price">{html.escape(price)}</td>'
f'<td><span class="{direction}">{sign}{html.escape(change)} ({sign}{html.escape(pct)}%)</span></td>'
f'</tr>'
)
index_section = ''
if index_rows:
index_section = (
'<div class="market-section">'
'<table class="market-table market-index">'
f'<tbody>{"".join(index_rows)}</tbody>'
'</table>'
'</div>'
)
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'<tr>'
f'<th>{html.escape(r["label"])}</th>'
f'<td><span class="{p_cls}">{p_str}</span></td>'
f'<td><span class="{f_cls}">{f_str}</span></td>'
f'<td><span class="{i_cls}">{i_str}</span></td>'
f'</tr>'
)
date_suffix = f' <span class="market-date">{date_label}</span>' if date_label else ''
refresh_btn = (
'<button type="button" class="market-refresh-btn" data-refresh-market '
'title="시장 지표 새로고침" aria-label="시장 지표 새로고침">↻</button>'
)
return (
'<div class="market-card">'
f'<div class="market-card-title">오늘의 시장{date_suffix}{refresh_btn}</div>'
+ index_section +
'<div class="market-section">'
'<div class="market-section-head"><span class="market-section-label">투자자별 매매</span><span class="market-section-hint">순매수 금액 · 단위 억원</span></div>'
'<table class="market-table market-deal">'
'<thead><tr><th></th><th>개인</th><th>외국인</th><th>기관</th></tr></thead>'
f'<tbody>{"".join(deal_rows)}</tbody>'
'</table>'
'</div>'
'</div>'
)
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'<button type="button" class="adr-range-btn{" active" if d == selected_days else ""}"'
f' data-adr-range="{d}" aria-pressed="{"true" if d == selected_days else "false"}">'
f'{html.escape(lbl)}</button>'
for lbl, d in ADR_TREND_PRESETS
)
return f'<div class="adr-trend-controls" role="group" aria-label="ADR 추세 기간">{btns}</div>'
_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 (
'<div class="market-card adr-trend-card">'
f'<div class="market-card-title">ADR 추세 <span class="market-card-sub">{ADR_MA_WINDOW}일 이평 · '
+ html.escape(sel_label) + '</span></div>'
+ summary_block
+ f'<div class="market-section-hint">{html.escape(hint)}</div>'
+ _render_adr_trend_controls(days)
+ '</div>'
)
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'<line x1="{PAD_L:.1f}" x2="{W-PAD_R:.1f}" y1="{y100:.1f}" y2="{y100:.1f}" stroke="#8b8f9a" stroke-width="0.6" stroke-dasharray="2 4" opacity="0.2"/>'
f'<line x1="{PAD_L:.1f}" x2="{W-PAD_R:.1f}" y1="{y75:.1f}" y2="{y75:.1f}" stroke="#4a8cf0" stroke-width="0.6" stroke-dasharray="3 3" opacity="0.22"/>'
f'<line x1="{PAD_L:.1f}" x2="{W-PAD_R:.1f}" y1="{y125:.1f}" y2="{y125:.1f}" stroke="#ff4d5e" stroke-width="0.6" stroke-dasharray="3 3" opacity="0.22"/>'
f'<text x="{W-PAD_R+3:.1f}" y="{y100:.1f}" dominant-baseline="middle" fill="#8b8f9a" font-size="9" opacity="0.35">100</text>'
f'<text x="{W-PAD_R+3:.1f}" y="{y75:.1f}" dominant-baseline="middle" fill="#4a8cf0" font-size="9" opacity="0.4">75</text>'
f'<text x="{W-PAD_R+3:.1f}" y="{y125:.1f}" dominant-baseline="middle" fill="#ff4d5e" font-size="9" opacity="0.4">125</text>'
)
# 시장별 이평선 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'<path d="M {d_attr}" fill="none" stroke="{color}" stroke-width="1.4"/>')
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'<circle cx="{lx:.1f}" cy="{ly:.1f}" r="3.0" fill="#11141c" stroke="{color}" stroke-width="1.6"/>'
f'<text x="{lx+6:.1f}" y="{ly:.1f}" dominant-baseline="middle" fill="{color}" font-size="10" font-weight="600">{last_v:.1f}*</text>'
)
else:
marker_parts.append(
f'<circle cx="{lx:.1f}" cy="{ly:.1f}" r="2.4" fill="{color}"/>'
f'<text x="{lx+5:.1f}" y="{ly:.1f}" dominant-baseline="middle" fill="{color}" font-size="10" font-weight="600">{last_v:.1f}</text>'
)
# 호버 인디케이터 — 세로선 1개 + 시장당 점 2개 (없는 시장은 JS가 hidden 처리).
hover_g = (
f'<g class="adr-hover" opacity="0" pointer-events="none">'
f'<line class="adr-hover-line" x1="0" x2="0" y1="{PAD_T:.1f}" y2="{H-PAD_B:.1f}"/>'
f'<circle class="adr-hover-dot adr-hover-dot-kospi" cx="0" cy="0" r="3.2" fill="{_ADR_MARKET_COLORS["KOSPI"]}" stroke="#0b0d12" stroke-width="1.2"/>'
f'<circle class="adr-hover-dot adr-hover-dot-kosdaq" cx="0" cy="0" r="3.2" fill="{_ADR_MARKET_COLORS["KOSDAQ"]}" stroke="#0b0d12" stroke-width="1.2"/>'
f'</g>'
)
# 포인트 메타 — 날짜축 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 = (
'<div class="adr-trend-tip" aria-hidden="true">'
'<div class="tip-d">—</div>'
'<div class="tip-line tip-kospi"><span class="tip-mk">코스피</span><span class="tip-v">—</span></div>'
'<div class="tip-line tip-kosdaq"><span class="tip-mk">코스닥</span><span class="tip-v">—</span></div>'
'</div>'
)
# 시장 색 범례 — 기간 표시는 차트 하단 x축으로 이동(축 라벨로 더 적합).
legend_html = (
'<div class="adr-combo-meta muted small">'
f'<span class="adr-legend"><span class="adr-legend-swatch" style="background:{_ADR_MARKET_COLORS["KOSPI"]}"></span>코스피</span>'
f'<span class="adr-legend"><span class="adr-legend-swatch" style="background:{_ADR_MARKET_COLORS["KOSDAQ"]}"></span>코스닥</span>'
'</div>'
)
# 차트 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'<text x="{PAD_L + PLOT_W/2:.1f}" y="{x_axis_y:.1f}" text-anchor="middle" '
f'fill="#6b7280" font-size="8.5" opacity="0.7">{html.escape(first_dt[5:])}</text>'
)
else:
x_axis_label = (
f'<text x="{PAD_L:.1f}" y="{x_axis_y:.1f}" text-anchor="start" '
f'fill="#6b7280" font-size="8.5" opacity="0.7">{html.escape(first_dt[5:])}</text>'
f'<text x="{W-PAD_R:.1f}" y="{x_axis_y:.1f}" text-anchor="end" '
f'fill="#6b7280" font-size="8.5" opacity="0.7">{html.escape(last_dt[5:])}</text>'
)
sel_label = next((lbl for lbl, d in ADR_TREND_PRESETS if d == days), '맞춤')
chart_row = (
f'<div class="adr-trend-row adr-combo-row" data-adr-pts="{pts_json}" data-adr-vb="{W:.0f} {H:.0f}">'
f'<svg class="adr-trend-svg" viewBox="0 0 {W:.0f} {H:.0f}" preserveAspectRatio="none" role="img" aria-label="코스피·코스닥 ADR 추세">'
f'{guides}'
f'{"".join(line_parts)}'
f'{"".join(marker_parts)}'
f'{x_axis_label}'
f'{hover_g}'
f'</svg>'
f'{tip_html}'
f'</div>'
)
return (
'<div class="market-card adr-trend-card">'
f'<div class="market-card-title">ADR 추세 <span class="market-card-sub">{ADR_MA_WINDOW}일 이평 · '
+ html.escape(sel_label) + '</span></div>'
+ summary_block
+ f'<div class="market-section-hint">{ADR_MA_WINDOW}일 이동평균 ADR · 회색점선=100(균형) · 파란점선=75(침체) · 빨간점선=125(과열) · *=오늘(라이브 포함)</div>'
+ _render_adr_trend_controls(days)
+ legend_html
+ chart_row
+ '</div>'
)
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 '<div class="empty">계좌 데이터가 없습니다.</div>'
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 = (
'<div class="account-view-pane" data-account-view-pane="consolidated">'
+ '\n'.join(consolidated_parts)
+ '</div>'
'<div class="account-view-pane" data-account-view-pane="by-account" hidden>'
+ '\n'.join(by_account_parts)
+ '</div>'
)
return (
'<div class="sub-tab-panel" data-sub-panel="assets" role="tabpanel">'
+ assets_inner
+ '</div>'
'<div class="sub-tab-panel" data-sub-panel="chart" role="tabpanel" hidden>'
+ '\n'.join(chart_parts)
+ '</div>'
'<div class="sub-tab-panel" data-sub-panel="market" role="tabpanel" hidden>'
+ market_card
+ adr_trend_card
+ '</div>'
'<div class="sub-tabs sub-tabs-bottom" role="tablist">'
'<button type="button" class="sub-tab active" data-sub-tab="assets" role="tab" aria-selected="true">자산보기</button>'
'<button type="button" class="sub-tab" data-sub-tab="chart" role="tab" aria-selected="false">차트보기</button>'
'<button type="button" class="sub-tab" data-sub-tab="market" role="tab" aria-selected="false">시장정보</button>'
'<button type="button" class="sub-tab-order-btn" data-open-order-modal title="거래 — 종목은 모달에서 선택">💰 거래</button>'
'</div>'
)
def _render_watchlist_panel(cards: list[dict]) -> tuple[str, int]:
if not cards:
return '<div class="empty">감시종목이 비어있습니다.</div>', 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'<div class="section-label">보유중<span class="count">{len(held)}</span></div>')
sections.append('\n'.join(_render_row(c) for c in held))
if watching:
sections.append(f'<div class="section-label">매수전<span class="count">{len(watching)}</span></div>')
sections.append('\n'.join(_render_row(c) for c in watching))
if trash:
sections.append(f'<div class="section-label trash-label">삭제예정<span class="count">{len(trash)}</span></div>')
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 (
'<div class="add-trigger-wrap">'
'<button type="button" class="btn-add-trigger" data-modal-open="interest-modal">+ 관심종목 추가</button>'
'</div>'
)
def _render_interests_edit_modal() -> str:
"""등록된 관심종목의 매수가/목표가/손절가/메모 수정 모달. shell HTML 직속에 둠.
삭제 버튼은 모달 내부에 별도 form(interest-delete-form 클래스)로 두고
HTML5 form attribute로 modal-actions의 submit 버튼에 연결 — 행 actions에서 빼냈다."""
return (
'<div id="interest-edit-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="interest-edit-modal-title">'
'<div class="modal-overlay" data-modal-close="1"></div>'
'<div class="modal-box" role="document">'
'<div class="modal-head">'
'<button type="submit" form="interest-edit-delete-form" class="btn-delete">삭제</button>'
'<div class="modal-title" id="interest-edit-modal-title">관심종목 수정 — <span data-edit-title></span></div>'
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
'</div>'
'<form class="add-form" id="interest-edit-form" method="post" action="/interests/update">'
'<input type="hidden" name="stock">'
'<div class="add-form-grid">'
'<label>매수가 <input name="buy_price" inputmode="decimal" placeholder="비우면 제거" autocomplete="off"></label>'
'<label>목표가 <input name="target_price" inputmode="decimal" placeholder="비우면 제거" autocomplete="off"></label>'
'<label>손절가 <input name="stop_price" inputmode="decimal" placeholder="비우면 제거" autocomplete="off"></label>'
'<label>메모 <input name="memo" maxlength="120" placeholder="비우면 제거" autocomplete="off"></label>'
'</div>'
'<div class="form-feedback" data-feedback hidden></div>'
'</form>'
'<form id="interest-edit-delete-form" class="interest-delete-form" method="post" action="/interests/delete">'
'<input type="hidden" name="stock">'
'</form>'
'<div class="modal-actions">'
'<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
'<button type="submit" form="interest-edit-form" class="btn-add">저장</button>'
'</div>'
'</div>'
'</div>'
)
def _render_tag_modal() -> str:
"""종목 공용 태그 설정 모달. 어느 탭의 칩이든 +태그 버튼이든 동일하게 연다.
대장 버튼은 토글 — 클릭 즉시 set/clear & submit. 자유 입력은 텍스트칸 + 저장."""
return (
'<div id="tag-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="tag-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="tag-modal-title">종목 태그 — <span data-tag-title></span></div>'
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
'</div>'
'<form class="add-form" id="tag-form" method="post" action="/tags/set">'
'<input type="hidden" name="stock">'
'<input type="hidden" name="code">'
'<input type="hidden" name="leader" value="0">'
'<div class="tag-quick-row">'
'<button type="button" class="btn-tag-quick" data-tag-toggle="leader">대장</button>'
'<span class="muted small">색깔 토글 — 텍스트는 그대로 두고 빨강만 on/off</span>'
'</div>'
'<div class="add-form-grid">'
'<label>태그 <input name="tag" maxlength="20" placeholder="자유 입력 (예: 반도체, # 생략)" autocomplete="off"></label>'
'</div>'
'<div class="form-feedback" data-feedback hidden></div>'
'</form>'
'<div class="modal-actions">'
'<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
'<button type="button" class="btn-delete" data-tag-delete>삭제</button>'
'<button type="submit" form="tag-form" class="btn-add">저장</button>'
'</div>'
'</div>'
'</div>'
)
def _render_candidate_modal() -> str:
"""다중 매칭 시 사용자가 종목을 선택하는 별도 팝업. 추가 모달과 분리된 z-index 위층."""
return (
'<div id="candidate-modal" class="modal modal-top hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="candidate-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="candidate-modal-title">종목 선택</div>'
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
'</div>'
'<div class="feedback-title" data-cand-title></div>'
'<div class="candidate-list" data-cand-list></div>'
'<div class="modal-actions">'
'<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
'</div>'
'</div>'
'</div>'
)
def _render_stock_name_modal() -> str:
"""거래내역 표에서 종목 셀 길게 누르면 풀네임 표시 — press-and-hold tooltip.
셀 위쪽으로 띄워 손가락에 가리지 않게. pointerdown=show / pointerup=hide."""
return (
'<div id="stock-name-tip" class="stock-name-tip" role="tooltip" aria-hidden="true"></div>'
)
def _render_trade_modal() -> str:
"""종목별 거래내역 팝업. shell HTML 직속이라 panels swap 영향 없음. trigger 클릭 → fetch → table 렌더."""
return (
'<div id="trade-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="trade-modal-title">'
'<div class="modal-overlay" data-modal-close="1"></div>'
'<div class="modal-box modal-box-wide" role="document">'
'<div class="modal-head">'
'<div class="modal-title" id="trade-modal-title"><span data-trade-title></span></div>'
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
'</div>'
'<div class="trade-summary muted small" data-trade-summary></div>'
'<div class="trade-body" data-trade-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_info_modal() -> str:
"""종목별 기업정보 팝업 (시총·PER·PBR·EPS·BPS·ROE·52주·외국인비율 등).
shell HTML 직속이라 panels swap 영향 없음. '상세' 버튼 클릭 → /api/stock_info fetch → 렌더."""
return (
'<div id="info-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="info-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="info-modal-title"><span data-info-title>기업정보</span></div>'
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
'</div>'
# 기업비교 바 — 종목 검색 + 내 목록(보유·관심·감시) 드롭다운으로 비교 대상 추가.
'<div class="info-compare-bar">'
'<div class="info-search-row">'
'<input type="text" class="info-search-input" data-info-search placeholder="🔍 종목 검색 (이름·코드)" autocomplete="off">'
'<button type="button" class="info-search-btn" data-info-search-btn>검색</button>'
'</div>'
'<select class="info-symbols-select" data-info-symbols aria-label="내 목록에서 비교 추가">'
'<option value=""> 내 목록에서 추가</option>'
'</select>'
'</div>'
'<div class="info-search-results hidden" data-info-search-results></div>'
'<div class="info-body" data-info-body><div class="muted small">불러오는 중…</div></div>'
'<div class="modal-actions">'
'<a class="btn-analyze info-analyze-link" data-info-analyze href="#" target="_blank" rel="noopener" title="분석 보고서 페이지 열기">📊 분석 보고서</a>'
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
'</div>'
'</div>'
'</div>'
)
def _render_info_desc_modal() -> str:
"""지수 설명 팝업. info-modal 위에 겹쳐 뜨는 modal-top. ? 버튼 클릭 시 용어·설명 표시."""
return (
'<div id="info-desc-modal" class="modal hidden modal-top" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="info-desc-modal-title">'
'<div class="modal-overlay" data-modal-close="1"></div>'
'<div class="modal-box info-desc-box" role="document">'
'<div class="modal-head">'
'<div class="modal-title" id="info-desc-modal-title"><span data-desc-term>지수 설명</span></div>'
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
'</div>'
'<div class="info-desc-body" data-desc-body></div>'
'<div class="modal-actions">'
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
'</div>'
'</div>'
'</div>'
)
def _render_order_modal() -> str:
"""주문 진입 모달 (매수/매도). 호가창 1초 폴링, PIN OTP 흐름.
1단계: 종목·계좌·수량·단가 입력 → /api/order/propose (PIN 텔레그램 발송)
2단계: PIN 입력 → /api/order/verify (실주문 실행)
3단계: 결과 표시
"""
return (
'<div id="order-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="order-modal-title">'
'<div class="modal-overlay" data-modal-close="1"></div>'
'<div class="modal-box order-box" role="document">'
'<div class="modal-head">'
'<div class="modal-title" id="order-modal-title" style="flex:1; min-width:0;">'
'<select class="order-symbol-select" data-order-symbol-select aria-label="종목 변경">'
'<option value="">종목 선택</option>'
'</select>'
'</div>'
'</div>'
'<div class="order-meta">'
'<span class="cur-price" data-order-cur-price>—</span>'
'<span class="change" data-order-change>—</span>'
'<span class="order-market-phase" data-market-phase>—</span>'
'</div>'
'<div class="order-modal-msg hidden" data-order-msg></div>'
# 1단계: 입력
'<div data-order-step="1">'
'<div class="order-side-toggle">'
'<button type="button" data-side="BUY" class="active">매수</button>'
'<button type="button" data-side="SELL">매도</button>'
'<button type="button" class="info-tab" data-show-active title="진행 중인 카드(PIN 발급된 거래) + 미체결 주문 종목 수">📋 진행중<span data-active-count></span></button>'
'</div>'
'<div class="order-body">'
'<div class="orderbook" data-orderbook>'
'<div class="loading" style="padding:30px 0;"><div class="loading-spin"></div>호가 로딩중…</div>'
'</div>'
'<div class="order-inputs">'
'<label class="inline-row">계좌<select data-order-account></select></label>'
'<label class="inline-row">주문유형<select data-order-type>'
'<option value="LIMIT">지정가</option>'
'<option value="MARKET">시장가</option>'
'</select></label>'
'<label data-order-price-row>단가'
'<input type="number" inputmode="numeric" min="0" step="1" data-order-price-input>'
'<div class="price-tick-buttons">'
'<button type="button" data-tick-dir="-1">▼ 한 호가 아래</button>'
'<button type="button" data-tick-dir="1">▲ 한 호가 위</button>'
'</div>'
'</label>'
'<label data-order-budget-row>'
'<span>금액 <span class="muted small" style="font-weight:400;">(매수액 → 수량 자동)</span></span>'
'<input type="number" inputmode="numeric" min="0" step="1000" placeholder="예: 1000000" data-order-budget>'
'</label>'
'<label><span>수량 <span class="max-qty" data-order-max>(최대: —)</span></span>'
'<input type="number" inputmode="numeric" min="0" step="1" data-order-qty>'
'<div class="qty-step-buttons" data-qty-buttons></div>'
'</label>'
'<div class="order-info" data-order-info>—</div>'
'<div class="order-actions">'
'<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
'<button type="button" class="btn-confirm" data-order-submit data-side="BUY">매수</button>'
'</div>'
'</div>'
'</div>'
'</div>'
'</div>'
'</div>'
)
def _render_open_orders_modal() -> str:
"""매매등록된 미체결 주문 목록 팝업. [📋 진행중] 탭이 표시 — 4계좌 통합 + 행별 취소.
각 행: 종목·계좌·방향·수량·단가·상태 + [취소] 버튼 → POST /api/orders/cancel.
"""
return (
'<div id="open-orders-modal" class="modal hidden modal-top" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="open-orders-modal-title">'
'<div class="modal-overlay" data-modal-close="1"></div>'
'<div class="modal-box open-orders-box" role="document">'
'<div class="modal-head">'
'<div class="modal-title" id="open-orders-modal-title">📋 진행중 매매</div>'
'</div>'
'<div class="open-orders-list" data-open-orders-list>'
'<div class="loading" style="padding:24px 0;"><div class="loading-spin"></div>불러오는 중…</div>'
'</div>'
'<div class="modal-actions">'
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
'<button type="button" class="btn-add" data-open-orders-refresh>새로고침</button>'
'</div>'
'</div>'
'</div>'
)
def _render_pin_modal() -> str:
"""매매 카드 PIN 입력 팝업. order-modal의 propose 성공 직후 띄움.
카드 요약 + PIN 입력 칸 + 만료 카운트다운 + [카드 취소][주문 실행] 액션.
verify 결과는 같은 모달 안 결과 영역으로 swap.
"""
return (
'<div id="pin-modal" class="modal hidden modal-top" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="pin-modal-title">'
'<div class="modal-overlay" data-modal-close="1"></div>'
'<div class="modal-box pin-box" role="document">'
'<div class="modal-head">'
'<div class="modal-title" id="pin-modal-title">주문 확인 — <span data-pin-symbol>—</span></div>'
'</div>'
'<div class="order-modal-msg info" data-pin-card-summary>—</div>'
# PIN 입력
'<div data-pin-input-step>'
'<div class="order-pin-row">'
'<label for="pin-modal-input">PIN — 텔레그램으로 발송됨 (iOS 자동입력 지원)</label>'
'<input id="pin-modal-input" type="text" autocomplete="one-time-code" inputmode="numeric" maxlength="8" data-pin-input>'
'</div>'
'<div class="order-countdown" data-pin-countdown>—</div>'
'<div class="order-actions">'
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
'<button type="button" class="btn-confirm" data-pin-verify>주문 실행</button>'
'</div>'
'</div>'
# 결과
'<div class="hidden" data-pin-result-step>'
'<div class="order-modal-msg info" data-pin-result>—</div>'
'<div class="order-actions">'
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
'</div>'
'</div>'
'</div>'
'</div>'
)
def _render_interests_modal() -> str:
"""shell HTML 직속에 두는 종목 추가 모달. panels API의 swap 영역(section.tab-content) 밖이라
자동 갱신 중에도 DOM·입력값이 보존된다."""
return (
'<div id="interest-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="interest-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="interest-modal-title">관심종목 추가</div>'
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
'</div>'
'<form class="add-form" id="interest-add-form" method="post" action="/interests/add">'
'<div class="add-form-grid">'
'<label>종목명 <input name="stock" maxlength="40" placeholder="예: 카카오 (코드 알면 생략)" autocomplete="off"></label>'
'<label>종목코드 <input name="code" maxlength="20" placeholder="예: 005930" inputmode="numeric" autocomplete="off"></label>'
'</div>'
'<div class="form-feedback" data-feedback hidden></div>'
'<div class="modal-actions">'
'<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
'<button type="submit" class="btn-add">추가</button>'
'</div>'
'</form>'
'</div>'
'</div>'
)
def _render_interests_panel(cards: list[dict]) -> tuple[str, int]:
sections: list[str] = []
if not cards:
sections.append('<div class="empty">관심종목이 비어있습니다.</div>')
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'<div class="section-label">보유중<span class="count">{len(held)}</span></div>')
sections.append('\n'.join(_render_row(c, source='interests') for c in held))
if watching:
sections.append(f'<div class="section-label">관심<span class="count">{len(watching)}</span></div>')
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'<a class="tab-label" href="#{tid}">{html.escape(label)}</a>'
for tid, label in descs
)
spinner_placeholder = (
'<div class="loading"><span class="loading-spin"></span>'
'<span class="loading-text">불러오는 중…</span></div>'
)
content_html = '\n'.join(
f'<section class="tab-content" data-tab="{tid}">{spinner_placeholder}</section>'
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 = (
'<script>(function(){'
f'var V=[{valid_tids_js}],FIRST="{first_tid}";'
'function s(){var h=(location.hash||"").replace(/^#/,"");'
'document.documentElement.setAttribute("data-tab",V.indexOf(h)>=0?h:FIRST);}'
's();'
# 탭 이동마다 강제 새로고침(fresh) — owner 탭이면 그 owner partial, 그 외(자산정보·관심·감시)는 전체.
'addEventListener("hashchange",function(){'
's();'
'if(window.__behive_set_meta_active)window.__behive_set_meta_active();'
'if(window.__behive_load){'
'var a=document.documentElement.getAttribute("data-tab")||"";'
'var ow=(a==="tab-self"||a==="tab-gahee")?a.slice(4):null;'
'window.__behive_load(ow,true);'
'}'
'});'
'})();</script>'
)
# 패널 fetch + DOM swap.
# load() — 전체 (모든 탭 채움, 첫 진입·새로고침·PTR·자산정보 탭 진입)
# load("self"|"gahee") — owner 탭 하나만 받아 partial swap (자동 갱신)
# 응답 tabs는 받은 만큼만 swap하므로 보지 않는 owner의 stale 데이터는 그대로 둠.
# inFlight를 key별로 분리해 owner 갱신과 전체 fetch가 충돌하지 않도록.
panels_script = (
'<script>(function(){'
'function setMeta(s){var m=document.querySelector("header.top .meta");if(m)m.textContent=s?("갱신 "+s):"";}'
# 탭별 fetched_at 누적 dict — partial 응답은 자기 탭만 갱신하므로 다른 탭은 이전 값 유지.
'var tabFetched={};'
'function setMetaActive(){'
'var a=document.documentElement.getAttribute("data-tab")||"";'
'setMeta(tabFetched[a]||"");'
'}'
'window.__behive_set_meta_active=setMetaActive;'
'function setMarket(act){'
'var btn=document.getElementById("auto-toggle");if(!btn)return;'
'var prev=window.__behive_market_active;'
'window.__behive_market_active=!!act;'
'if(act){btn.classList.remove("closed");btn.removeAttribute("disabled");'
'btn.title="10초마다 자동 갱신 (자산정보·관리자·가희·관심종목 탭에서 동작, 페이지를 보고 있을 때만)";}'
'else{btn.classList.add("closed");btn.setAttribute("disabled","");'
'btn.title="휴장 또는 장 마감 (NXT 평일 08:00~20:00 운영) — 자동 갱신 비활성";}'
'if(prev!==!!act&&window.__behive_auto_arm)window.__behive_auto_arm();'
'}'
'window.__behive_set_market=setMarket;'
'var tickerItems=[];'
'var arrowMap={up:"",down:"",flat:"-"};'
'function tickerItemHtml(item){'
'var d=item.direction||"flat";'
# label은 data-key로 박아 in-place 업데이트 매칭 키로 사용.
'var key=String(item.label).replace(/"/g,"&quot;");'
'return \'<span class="idx \'+(item.kind||"")+\'" data-key="\'+key+\'">\'+'
'\'<span class="idx-label">\'+item.label+\'</span>\'+'
'\'<span class="idx-price">\'+item.price+\'</span>\'+'
'\'<span class="idx-delta \'+d+\'">\'+arrowMap[d]+\' \'+item.change+\' (\'+item.pct+\'%)</span></span>\';'
'}'
# 같은 세트를 2번 렌더 → CSS translateX(-50%)로 seamless 무한 스크롤.
'function renderTicker(){'
'var el=document.getElementById("market-ticker");if(!el)return;'
'if(!tickerItems.length){el.innerHTML="<span class=\\"idx-empty\\">데이터 없음</span>";return;}'
'var setHtml=tickerItems.map(tickerItemHtml).join("")+\'<span class="ticker-sep">·</span>\';'
'el.innerHTML=\'<div class="ticker-track">\'+'
'\'<div class="ticker-set">\'+setHtml+\'</div>\'+'
'\'<div class="ticker-set" aria-hidden="true">\'+setHtml+\'</div>\'+'
'\'</div>\';'
# intro 속도 = scroll 속도가 되도록 동적 계산.
# scroll은 20s에 트랙 절반(=한 세트 너비) 이동 → px/s = setW/20.
# intro 거리는 viewport 너비(100vw) → duration = viewport / (setW/20) = viewport*20/setW.
# 2~12s 캡: 너무 짧으면 pop-in, 너무 길면 사용자가 첫 등장 기다림.
'var trk=el.querySelector(".ticker-track");'
'if(trk){'
'var setW=trk.scrollWidth/2,vw=window.innerWidth||document.documentElement.clientWidth||0;'
'if(setW>0&&vw>0){'
'var d=vw*20/setW;'
'if(d<2)d=2;else if(d>12)d=12;'
'trk.style.animation="ticker-intro "+d+"s linear, ticker-scroll 20s linear "+d+"s infinite";'
'}'
'}'
'}'
# 항목 키 셋이 동일하면 가격·등락만 in-place 갱신 → CSS 애니메이션 진행 상태 보존.
# 자동 갱신 중에도 스크롤이 0초부터 리셋되지 않는다.
'function updateTickerInPlace(arr){'
'var el=document.getElementById("market-ticker");if(!el)return false;'
'var nodes=el.querySelectorAll(".idx[data-key]");if(!nodes.length)return false;'
'var byKey={};'
'for(var i=0;i<nodes.length;i++){'
'var k=nodes[i].getAttribute("data-key");'
'(byKey[k]=byKey[k]||[]).push(nodes[i]);'
'}'
'if(Object.keys(byKey).length!==arr.length)return false;'
'for(var j=0;j<arr.length;j++){if(!byKey[arr[j].label])return false;}'
'arr.forEach(function(item){'
'var d=item.direction||"flat";'
'var deltaText=arrowMap[d]+" "+item.change+" ("+item.pct+"%)";'
'byKey[item.label].forEach(function(n){'
'var price=n.querySelector(".idx-price");'
'var delta=n.querySelector(".idx-delta");'
'if(price)price.textContent=item.price;'
'if(delta){delta.className="idx-delta "+d;delta.textContent=deltaText;}'
'});'
'});'
'return true;'
'}'
# 빈 배열이면 기존 표시 유지 (owner-only 응답에서 ticker_items 비어도 stale 보존).
'function setTicker(arr){'
'if(!arr||!arr.length){if(!tickerItems.length)renderTicker();return;}'
'if(updateTickerInPlace(arr)){tickerItems=arr;return;}'
'tickerItems=arr;'
'renderTicker();'
'}'
'function apply(p){'
'setMarket(!!p.market_active);'
'setTicker(p.ticker_items||p.indices||[]);'
'var byId={};(p.tabs||[]).forEach(function(t){'
'byId[t.id]=t;'
# partial 응답이라도 자기 탭만 fetched_at 갱신 — 다른 탭 값은 그대로.
'if(t.fetched_at)tabFetched[t.id]=t.fetched_at;'
'});'
'setMetaActive();'
'document.querySelectorAll("nav.tabs a.tab-label").forEach(function(a){'
'var id=(a.getAttribute("href")||"").replace(/^#/,"");'
'var t=byId[id];if(t)a.textContent=t.label;'
'});'
'document.querySelectorAll("section.tab-content").forEach(function(sec){'
'var id=sec.getAttribute("data-tab");var t=byId[id];'
'if(!t)return;'
'var openKeys={};'
'sec.querySelectorAll("details.row[open][data-row-key]").forEach(function(d){'
'openKeys[d.getAttribute("data-row-key")]=1;'
'});'
'sec.innerHTML=t.html;'
'Object.keys(openKeys).forEach(function(k){'
'var d=sec.querySelector(\'details.row[data-row-key="\'+k.replace(/"/g,\'\\\\"\')+\'"]\');'
'if(d)d.setAttribute("open","");'
'});'
'});'
'document.dispatchEvent(new CustomEvent("behive:panels-loaded",{detail:p}));'
'}'
'function failVisible(msg){'
'document.querySelectorAll("section.tab-content").forEach(function(sec){'
'if(sec.querySelector(".loading"))sec.innerHTML="<div class=\\"empty\\">"+msg+"</div>";'
'});'
'}'
'var inFlight={};'
# 마지막 성공 갱신 시각 — 5분 초과 시 다음 load는 첫 진입처럼 spinner 리셋 + 전체 fetch로 승격.
'var lastLoadAt=0,STALE_MS=5*60*1000;'
'var SPINNER=\'<div class="loading"><span class="loading-spin"></span><span class="loading-text">불러오는 중…</span></div>\';'
'function resetShellLook(){'
'document.querySelectorAll("section.tab-content").forEach(function(sec){sec.innerHTML=SPINNER;});'
'var tk=document.getElementById("market-ticker");'
'if(tk){tk.innerHTML=\'<span class="idx-empty">지수 불러오는 중…</span>\';tickerItems=[];}'
'}'
'function chartDays(){'
'var v=parseInt(localStorage.getItem("behive.chartDaysV2"),10);'
'if(isNaN(v))return null;'
'return v;'
'}'
'function chartUnit(){'
'var v=localStorage.getItem("behive.chartUnit");'
'if(!v)return null;'
'if(v!=="day"&&v!=="week"&&v!=="month"&&v!=="year")return null;'
'return v;'
'}'
'function chartMode(){'
'var v=localStorage.getItem("behive.chartMode");'
'if(!v)return null;'
'if(v!=="net"&&v!=="pnl"&&v!=="period")return null;'
'return v;'
'}'
'function adrDays(){'
'var v=parseInt(localStorage.getItem("behive.adrTrendDays"),10);'
'if(isNaN(v))return null;'
# 허용된 preset 값만 통과 (서버 sanity와 일치). preset 변경 시 양쪽 같이 갱신.
'if(v!==7&&v!==30&&v!==90&&v!==180&&v!==365&&v!==-1)return null;'
'return v;'
'}'
'window.__behive_chart_days=chartDays;'
'window.__behive_chart_unit=chartUnit;'
'window.__behive_chart_mode=chartMode;'
'window.__behive_adr_days=adrDays;'
'function load(owner,fresh){'
# 5분 이상 stale 상태에서 들어오면 모든 탭을 새로 채워야 하니 owner 부분 fetch도 전체로 승격.
'if(lastLoadAt&&(Date.now()-lastLoadAt)>STALE_MS){resetShellLook();owner=null;}'
'var key=owner||"all";'
'if(!fresh&&inFlight[key])return inFlight[key];'
'var qs=[];if(owner)qs.push("owner="+encodeURIComponent(owner));if(fresh)qs.push("fresh=1");'
'var cd=chartDays();if(cd!==null)qs.push("chart_days="+cd);'
'var cu=chartUnit();if(cu!==null)qs.push("chart_unit="+cu);'
'var cm=chartMode();if(cm!==null)qs.push("chart_mode="+cm);'
'var ad=adrDays();if(ad!==null)qs.push("adr_days="+ad);'
'var url="/api/panels"+(qs.length?("?"+qs.join("&")):"");'
'var pr=fetch(url,{cache:"no-store",credentials:"same-origin"})'
'.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json();})'
'.then(function(j){apply(j);lastLoadAt=Date.now();})'
'.catch(function(){failVisible("갱신 실패 — 다시 시도해주세요");});'
'var done=function(){if(!fresh)delete inFlight[key];};'
'pr.finally?pr.finally(done):pr.then(done,done);'
'if(!fresh)inFlight[key]=pr;'
'return pr;'
'}'
'function activeOwner(){'
'var a=document.documentElement.getAttribute("data-tab")||"";'
'return (a==="tab-self"||a==="tab-gahee")?a.slice(4):null;'
'}'
'var spinCount=0;'
'function manualRefresh(){'
'var btn=document.querySelector("a.refresh");'
'spinCount++;'
'if(btn)btn.classList.add("spinning");'
'var p=load(activeOwner(),true);'
'var done=function(){'
'spinCount=Math.max(0,spinCount-1);'
'if(spinCount===0&&btn)btn.classList.remove("spinning");'
'if(window.__behive_auto_arm)window.__behive_auto_arm();'
'};'
'if(p&&p.finally)p.finally(done);'
'else if(p&&p.then)p.then(done,done);'
'else done();'
'}'
'window.__behive_load=load;'
'window.__behive_active_owner=activeOwner;'
'window.__behive_manual_refresh=manualRefresh;'
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){load();});'
'else load();'
'})();</script>'
)
# 순자산 차트 인터랙션: 호버/터치 시 indicator 세로선 + 툴팁(그날 손익·누적 손익).
# data-pts JSON 파싱 → pointermove로 최근접 포인트 찾아 indicator·tooltip 갱신.
# MutationObserver로 swap 후 새 .net-chart 자동 재바인딩.
netchart_script = (
'<script>(function(){'
'var W=800;'
'function fmt(n){return (n<0?"-":"+")+Math.abs(n).toLocaleString()+"";}'
'function cls(n){return n>0?"up":(n<0?"down":"neutral");}'
'function init(root){'
'if(root.__nw_bound)return;root.__nw_bound=true;'
'var svg=root.querySelector("svg.net-chart-svg");'
'var tip=root.querySelector(".net-chart-tip");'
'var hg=svg&&svg.querySelector("g.nw-hover");'
'if(!svg||!tip||!hg)return;'
'var line=hg.querySelector("line"),dot=hg.querySelector("circle");'
'var pts;try{pts=JSON.parse(root.getAttribute("data-pts")||"[]");}catch(e){pts=[];}'
'if(!pts.length)return;'
'var dEl=tip.querySelector(".tip-date");'
'var dayEl=tip.querySelector(".tip-day");'
'var totEl=tip.querySelector(".tip-total");'
'var netEl=tip.querySelector(".tip-net");'
'var cfRow=tip.querySelector(".tip-cf-row");'
'var cfEl=tip.querySelector(".tip-cf");'
'var hideTimer=null;'
'function scheduleHide(){if(hideTimer)clearTimeout(hideTimer);hideTimer=setTimeout(clear,15000);}'
'function applyPoint(p){'
'line.setAttribute("x1",p.x);line.setAttribute("x2",p.x);'
'dot.setAttribute("cx",p.x);dot.setAttribute("cy",p.y);'
'hg.setAttribute("opacity","1");'
# 날짜 라벨 — unit 단위에 맞춰: year=YYYY, month=YYYY-MM, week/day=YYYY-MM-DD (요일)
'var unit=root.getAttribute("data-chart-unit")||"day";'
'var dateLabel;'
'if(unit==="year"){dateLabel=p.d.substring(0,4);}'
'else if(unit==="month"){dateLabel=p.d.substring(0,7);}'
'else{var dt=new Date(p.d+"T00:00:00+09:00");'
'var dow=isNaN(dt.getTime())?"":["","","","","","",""][dt.getDay()];'
'dateLabel=p.d+(dow?" ("+dow+")":"");}'
'dEl.textContent=dateLabel;'
'dayEl.textContent=fmt(p.day);dayEl.className="tip-day "+cls(p.day);'
'totEl.textContent=fmt(p.total);totEl.className="tip-total "+cls(p.total);'
'netEl.textContent=p.v.toLocaleString()+"";'
# 입출금 행: cf_net 있으면 표시, 없으면 hide. 입금만/출금만/둘다 케이스 분기.
'if(cfRow&&cfEl){'
'if(typeof p.cf_net==="number"){'
'var ci=p.cf_in||0,co=p.cf_out||0,parts=[];'
'if(ci)parts.push("입금 "+ci.toLocaleString()+"");'
'if(co)parts.push("출금 "+co.toLocaleString()+"");'
'cfEl.textContent=parts.join(" · ")||"";'
'cfEl.className="tip-cf "+cls(p.cf_net);'
'cfRow.hidden=false;'
'}else{cfRow.hidden=true;}'
'}'
'tip.style.opacity="1";'
'scheduleHide();'
'requestAnimationFrame(function(){'
'var r=svg.getBoundingClientRect();if(!r.width)return;'
'var rr=root.getBoundingClientRect();'
'var pxX=p.x/W*r.width+(r.left-rr.left);'
'var tw=tip.offsetWidth;'
'var lx=pxX-tw/2;'
'var maxL=rr.width-tw-4;'
'if(lx<4)lx=4;if(lx>maxL)lx=maxL;'
'tip.style.left=lx+"px";'
# top: indicator 점(=사용자 손가락 근처)이 차트 위쪽 절반이면 tooltip을 하단,
# 아래쪽 절반이면 상단에 배치 → 터치 지점·indicator 라인 항상 피함.
'var svgTop=r.top-rr.top;'
'var th=tip.offsetHeight;'
'var ty=(p.y<90)?(svgTop+r.height-th-4):(svgTop+4);'
'tip.style.top=ty+"px";'
'});'
'}'
'function moveByPx(cx){'
'var r=svg.getBoundingClientRect();if(!r.width)return;'
'var px=(cx-r.left)/r.width*W;'
'var bi=0,bd=Math.abs(pts[0].x-px);'
'for(var i=1;i<pts.length;i++){var d=Math.abs(pts[i].x-px);if(d<bd){bd=d;bi=i;}}'
'root.__nw_idx=bi;applyPoint(pts[bi]);'
'}'
# 인덱스 단위 좌/우 이동. 첫 클릭이면 prev는 마지막 포인트, next는 첫 포인트에서 시작.
'function step(dir){'
'var idx=(typeof root.__nw_idx==="number")?root.__nw_idx:(dir>0?-1:pts.length);'
'idx+=dir;'
'if(idx<0)idx=0;'
'if(idx>=pts.length)idx=pts.length-1;'
'root.__nw_idx=idx;applyPoint(pts[idx]);'
'}'
'function clear(){if(hideTimer){clearTimeout(hideTimer);hideTimer=null;}hg.setAttribute("opacity","0");tip.style.opacity="0";delete root.__nw_idx;}'
'root.__nw_step=step;root.__nw_clear=clear;'
# 터치/마우스 떠나도 indicator·툴팁 유지. 다음 차트 터치/호버로만 위치 변경.
'svg.addEventListener("pointermove",function(e){moveByPx(e.clientX);});'
'svg.addEventListener("pointerdown",function(e){moveByPx(e.clientX);});'
'}'
# 기간 chip + 날짜 nav 버튼 — document 위임. swap/MutationObserver 타이밍 의존 없이 동작.
# modal_script와는 closest selector가 달라 충돌 없음.
# 지수 모달 안 sparkline 차트 — 축 라벨(Y min/max, X 시작/끝일) + 클릭 시 세로선 인디케이터 + 툴팁.
'window.__behive_render_idx_chart=function(host,series){'
'if(!series||series.length<2){host.innerHTML=\'<div class="idx-chart-err">차트 데이터 없음</div>\';return;}'
'var W=320,H=110,L=44,R=10,T=8,B=20;var iw=W-L-R,ih=H-T-B;var n=series.length;'
'var vs=series.map(function(p){return p.v;});'
'var mn=Math.min.apply(null,vs),mx=Math.max.apply(null,vs);'
'var sp=Math.max(mx-mn,1e-9);'
'function xAt(i){return L+(i/(n-1))*iw;}'
'function yAt(v){return T+(1-(v-mn)/sp)*ih;}'
'var pts=series.map(function(p,i){return{d:p.d,v:p.v,x:+xAt(i).toFixed(2),y:+yAt(p.v).toFixed(2)};});'
'var up=vs[vs.length-1]>=vs[0];var col=up?"#ff4d5e":"#4a8cf0";'
'var dPath="M "+pts.map(function(p){return p.x+","+p.y;}).join(" L ");'
# Y축 라벨 (max·mid·min) + X축 라벨 (시작·끝일 MM-DD)
'function fmtNum(v){return (v>=1000?v.toFixed(0):(v>=10?v.toFixed(2):v.toFixed(3)));}'
'var midV=(mn+mx)/2;'
'var yLabels=\'\';'
'yLabels+=\'<text class="idx-axis" x="\'+(L-4)+\'" y="\'+(T+3)+\'" text-anchor="end">\'+fmtNum(mx)+\'</text>\';'
'yLabels+=\'<text class="idx-axis" x="\'+(L-4)+\'" y="\'+(yAt(midV)+3)+\'" text-anchor="end">\'+fmtNum(midV)+\'</text>\';'
'yLabels+=\'<text class="idx-axis" x="\'+(L-4)+\'" y="\'+(T+ih+3)+\'" text-anchor="end">\'+fmtNum(mn)+\'</text>\';'
'var startD=series[0].d.slice(5),endD=series[n-1].d.slice(5);'
'var xLabels=\'\';'
'xLabels+=\'<text class="idx-axis" x="\'+L+\'" y="\'+(H-6)+\'" text-anchor="start">\'+startD+\'</text>\';'
'xLabels+=\'<text class="idx-axis" x="\'+(L+iw)+\'" y="\'+(H-6)+\'" text-anchor="end">\'+endD+\'</text>\';'
'var grid=\'\';'
'grid+=\'<line class="idx-grid" x1="\'+L+\'" x2="\'+(L+iw)+\'" y1="\'+(yAt(midV))+\'" y2="\'+(yAt(midV))+\'"/>\';'
'var lx=pts[n-1].x,ly=pts[n-1].y;'
'var svgHtml=\'<svg viewBox="0 0 \'+W+\' \'+H+\'" class="idx-chart-svg" preserveAspectRatio="xMidYMid meet">\'+'
'grid+yLabels+xLabels+'
'\'<path d="\'+dPath+\'" fill="none" stroke="\'+col+\'" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/>\'+'
'\'<circle cx="\'+lx+\'" cy="\'+ly+\'" r="3" fill="\'+col+\'"/>\'+'
# hover/click 인디케이터 — 초기 숨김
'\'<g class="idx-hover" opacity="0" pointer-events="none">\'+'
'\'<line class="idx-hover-line" x1="0" x2="0" y1="\'+T+\'" y2="\'+(T+ih)+\'"/>\'+'
'\'<circle class="idx-hover-dot" cx="0" cy="0" r="3.4"/>\'+'
'\'</g>\'+'
'\'</svg>\';'
'host.innerHTML=svgHtml+\'<div class="idx-chart-tip" hidden><span class="tip-d"></span><span class="tip-v"></span><button type="button" class="tip-close" data-tip-close aria-label="닫기">×</button></div>\'+\'<div class="idx-chart-foot"><span>1개월 추이</span><span>\'+n+\'개 영업일</span></div>\';'
'var svg=host.querySelector("svg.idx-chart-svg");'
'var hg=svg.querySelector("g.idx-hover"),hl=hg.querySelector("line"),hd=hg.querySelector("circle");'
'var tip=host.querySelector(".idx-chart-tip");'
'var tipD=tip.querySelector(".tip-d"),tipV=tip.querySelector(".tip-v");'
'function apply(p){'
'hl.setAttribute("x1",p.x);hl.setAttribute("x2",p.x);'
'hd.setAttribute("cx",p.x);hd.setAttribute("cy",p.y);hd.setAttribute("fill",col);'
'hg.setAttribute("opacity","1");'
'tipD.textContent=p.d;tipV.textContent=fmtNum(p.v);tip.hidden=false;'
'}'
'function nearestByPx(cx){'
'var r=svg.getBoundingClientRect();if(!r.width)return null;'
'var px=(cx-r.left)/r.width*W;'
'var bi=0,bd=Math.abs(pts[0].x-px);'
'for(var i=1;i<pts.length;i++){var d=Math.abs(pts[i].x-px);if(d<bd){bd=d;bi=i;}}'
'return pts[bi];'
'}'
'function onPtr(e){var p=nearestByPx(e.clientX);if(p)apply(p);}'
'svg.addEventListener("click",onPtr);'
'svg.addEventListener("pointermove",function(e){if(e.pressure>0||e.buttons>0)onPtr(e);});'
# tip 닫기 버튼 — hover 인디케이터 + 툴팁 둘 다 숨김
'var closeBtn=tip.querySelector("[data-tip-close]");'
'if(closeBtn)closeBtn.addEventListener("click",function(e){e.preventDefault();e.stopPropagation();tip.hidden=true;hg.setAttribute("opacity","0");});'
'};'
# 시장 카드 지수 설명 ⓘ 버튼 — 클릭 시 idx-info-modal 팝업 (제목/본문 동적 채움)
'document.addEventListener("click",function(e){'
'var ib=e.target.closest&&e.target.closest("[data-idx-info]");'
'if(!ib)return;'
'e.preventDefault();'
'var label=ib.getAttribute("data-idx-label")||"지수";'
'var desc=ib.getAttribute("data-idx-desc")||"";'
'var price=ib.getAttribute("data-idx-price")||"";'
'var change=ib.getAttribute("data-idx-change")||"";'
'var pct=ib.getAttribute("data-idx-pct")||"";'
'var dir=ib.getAttribute("data-idx-direction")||"flat";'
'var sign=dir==="up"?"+":(dir==="down"?"":"");'
'var modal=document.getElementById("idx-info-modal");if(!modal)return;'
'var tEl=modal.querySelector("[data-idx-modal-title]");'
'var bEl=modal.querySelector("[data-idx-modal-body]");'
'var pEl=modal.querySelector("[data-idx-modal-price]");'
'var cEl=modal.querySelector("[data-idx-modal-change]");'
'if(tEl)tEl.textContent=label;'
'if(bEl)bEl.textContent=desc;'
'if(pEl)pEl.textContent=price||"";'
'if(cEl){cEl.textContent=change?(sign+change+" ("+sign+pct+"%)"):"";cEl.className="idx-info-change "+dir;}'
# 차트 lazy fetch — symbol 있으면 /api/idx-chart 호출 후 SVG 동적 렌더. window.__behive_render_idx_chart 호출.
'var symbol=ib.getAttribute("data-idx-symbol")||"";'
'var chEl=modal.querySelector("[data-idx-modal-chart]");'
'if(chEl){'
'if(!symbol){chEl.hidden=true;chEl.innerHTML="";}'
'else{'
'chEl.hidden=false;'
'chEl.innerHTML=\'<div class="idx-chart-loading">차트 불러오는 중…</div>\';'
'fetch("/api/idx-chart?symbol="+encodeURIComponent(symbol),{cache:"default"})'
'.then(function(r){return r.ok?r.json():Promise.reject();})'
'.then(function(j){if(window.__behive_render_idx_chart)window.__behive_render_idx_chart(chEl,(j&&j.series)||[]);})'
'.catch(function(){chEl.innerHTML=\'<div class="idx-chart-err">차트 불러오기 실패</div>\';});'
'}'
'}'
'modal.classList.remove("hidden");'
'modal.setAttribute("aria-hidden","false");'
'if(window.__behiveLockScroll)window.__behiveLockScroll();'
'});'
# 시장 카드 새로고침 버튼 — manual_refresh (fresh=1) 호출 → 서버 시장 캐시 무효화 + panels 다시 fetch
'document.addEventListener("click",function(e){'
'var rb=e.target.closest&&e.target.closest("[data-refresh-market]");'
'if(!rb)return;'
'e.preventDefault();'
'rb.classList.add("spinning");rb.disabled=true;'
'var done=function(){rb.classList.remove("spinning");rb.disabled=false;};'
'var p=window.__behive_manual_refresh&&window.__behive_manual_refresh();'
'if(p&&p.finally)p.finally(done);'
'else if(p&&p.then)p.then(done,done);'
'else setTimeout(done,1500);'
'});'
'document.addEventListener("click",function(e){'
'var chip=e.target.closest&&e.target.closest(".nw-chip");'
'if(chip){'
'e.preventDefault();'
'var rootC=chip.closest(".net-chart");if(!rootC)return;'
'var d=chip.getAttribute("data-chart-days");'
'var u=chip.getAttribute("data-chart-unit");'
'if(d!==null){try{localStorage.setItem("behive.chartDaysV2",d);}catch(_){}}'
'else if(u!==null){try{localStorage.setItem("behive.chartUnit",u);}catch(_){}}'
'else{return;}'
# 레거시 chip click 흐름은 유지(혹시 다른 곳 chip 남아있을 때 대비). 새 UI는 아래 select change로.
# active 토글은 같은 chip 그룹(parentNode = 한 .net-chart-options)으로 제한 — 다른 그룹 active 보존.
'var grp=chip.parentNode;'
'if(grp)grp.querySelectorAll(".nw-chip").forEach(function(x){x.classList.remove("active");});'
'chip.classList.add("active");'
'var ow=window.__behive_active_owner?window.__behive_active_owner():null;'
'if(window.__behive_load)window.__behive_load(ow,true);'
'return;'
'}'
'var nav=e.target.closest&&e.target.closest("[data-nw-step]");'
'if(nav){'
'e.preventDefault();e.stopPropagation();'
'var rootN=nav.closest(".net-chart");if(!rootN)return;'
'var act=nav.getAttribute("data-nw-step");'
'if(act==="clear"){if(rootN.__nw_clear)rootN.__nw_clear();}'
'else if(rootN.__nw_step){rootN.__nw_step(act==="next"?1:-1);}'
'return;'
'}'
'});'
# 자산정보 탭의 sub-tab (자산보기/차트보기/시장정보) 클릭 핸들러 + swap 후 복원.
'function applySub(root,key){'
'if(!root)return;'
'root.querySelectorAll(".sub-tab").forEach(function(x){'
'var on=x.getAttribute("data-sub-tab")===key;'
'x.classList.toggle("active",on);'
'x.setAttribute("aria-selected",on?"true":"false");'
'});'
'root.querySelectorAll(".sub-tab-panel").forEach(function(p){'
'if(p.getAttribute("data-sub-panel")===key)p.removeAttribute("hidden");'
'else p.setAttribute("hidden","");'
'});'
'}'
# 첫 페이지 진입 = '자산보기' default. 사용자가 차트보기 누른 뒤엔 자동 갱신 후에도 유지.
# localStorage 대신 in-memory 변수 — 새로고침/재진입 시 자산보기로 초기화된다.
'var subState="assets";'
'function restoreSubs(){'
'if(subState==="assets")return;'
'document.querySelectorAll(".sub-tabs").forEach(function(t){applySub(t.parentNode||t,subState);});'
'}'
'document.addEventListener("click",function(e){'
'var st=e.target.closest&&e.target.closest(".sub-tab");'
'if(!st)return;'
'var key=st.getAttribute("data-sub-tab");if(!key)return;'
'e.preventDefault();'
'var root=st.parentNode&&st.parentNode.parentNode;'
'applySub(root,key);'
'subState=key;'
'});'
# 합산/각계좌별도 단일 토글 — 클릭마다 두 상태 swap. owner KPI 카드·자산정보 sub-panel 어디서나
# 동일 in-memory state 공유. 패널 swap 후 restoreAccountViews가 모든 토글·pane을 다시 동기화.
'function applyAccountView(key){'
'document.querySelectorAll(".account-view-toggle-btn").forEach(function(b){'
'b.setAttribute("data-account-view-state",key);'
'});'
'document.querySelectorAll(".account-view-pane").forEach(function(p){'
'if(p.getAttribute("data-account-view-pane")===key)p.removeAttribute("hidden");'
'else p.setAttribute("hidden","");'
'});'
'}'
'var accountViewState="consolidated";'
'function restoreAccountViews(){'
'if(accountViewState==="consolidated")return;'
'applyAccountView(accountViewState);'
'}'
'document.addEventListener("click",function(e){'
'var bt=e.target.closest&&e.target.closest(".account-view-toggle-btn");'
'if(!bt)return;'
'e.preventDefault();'
'var cur=bt.getAttribute("data-account-view-state")||"consolidated";'
'var next=cur==="consolidated"?"by-account":"consolidated";'
'accountViewState=next;'
'applyAccountView(next);'
'});'
# ADR 추세 카드의 기간 버튼 (1주/1달/3달/6달/1년/전체) — localStorage 저장 후 전체 재fetch.
'document.addEventListener("click",function(e){'
'var bt=e.target.closest&&e.target.closest(".adr-range-btn");'
'if(!bt)return;'
'var v=bt.getAttribute("data-adr-range");if(v===null)return;'
'e.preventDefault();'
'try{localStorage.setItem("behive.adrTrendDays",v);}catch(_){}'
# 같은 그룹의 다른 버튼 active 해제 — 서버 응답 오기 전 즉시 시각 피드백.
'var grp=bt.parentNode;'
'if(grp){grp.querySelectorAll(".adr-range-btn").forEach(function(b){'
'var on=b===bt;'
'b.classList.toggle("active",on);'
'b.setAttribute("aria-pressed",on?"true":"false");'
'});}'
# 시장정보 sub-tab은 자산정보 탭의 일부 → owner=null full fetch (fresh=true).
'if(window.__behive_load)window.__behive_load(null,true);'
'});'
# select 드롭다운(기간·단위) 변경 핸들러 — 자산정보 탭 상단의 공통 컨트롤.
# chip 흐름과 동일한 localStorage 키 사용 → 백엔드 변경 없음.
'document.addEventListener("change",function(e){'
'var sel=e.target;'
'if(!sel||sel.tagName!=="SELECT"||!sel.classList||!sel.classList.contains("nw-select"))return;'
'if(sel.hasAttribute("data-nw-range")){'
'try{localStorage.setItem("behive.chartDaysV2",sel.value);}catch(_){}'
# 기간 변경 시 현재 단위가 비허용이면 첫 허용으로 자동 보정 (서버 sanity와 일치).
'var allowedMap={"-3":["day","week"],"-2":["day","week","month"],"-4":["day","week","month","year"],"-1":["day","week","month","year"]};'
'var allowed=allowedMap[sel.value]||["day","week","month","year"];'
'var unitSel=document.querySelector(\'select.nw-select[data-nw-unit]\');'
'var curU=unitSel?unitSel.value:"day";'
'if(allowed.indexOf(curU)<0){'
'try{localStorage.setItem("behive.chartUnit",allowed[0]);}catch(_){}'
'}'
'}else if(sel.hasAttribute("data-nw-unit")){'
'try{localStorage.setItem("behive.chartUnit",sel.value);}catch(_){}'
'}else if(sel.hasAttribute("data-nw-mode")){'
'try{localStorage.setItem("behive.chartMode",sel.value);}catch(_){}'
'}else{return;}'
'var ow=window.__behive_active_owner?window.__behive_active_owner():null;'
'if(window.__behive_load)window.__behive_load(ow,true);'
'});'
'function scan(){document.querySelectorAll(".net-chart[data-pts]").forEach(init);restoreSubs();restoreAccountViews();}'
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",scan);'
'else scan();'
# swap 후 새 차트가 들어오면 재바인딩 + sub-tab 복원
'new MutationObserver(scan).observe(document.body,{childList:true,subtree:true});'
'})();</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 = (
'<script>(function(){'
'function cls(v){return v>=125?"up":(v<=75?"down":"flat");}'
# selectedDate — 마지막 선택 날짜 ISO('YYYY-MM-DD'). 단일 차트라 시장 키 불필요.
'var selectedDate=null;'
'function init(row){'
'if(row.__adr_bound)return;row.__adr_bound=true;'
'var svg=row.querySelector("svg.adr-trend-svg");'
'var tip=row.querySelector(".adr-trend-tip");'
'var hg=svg&&svg.querySelector("g.adr-hover");'
'if(!svg||!tip||!hg)return;'
'var line=hg.querySelector("line");'
'var dotK=hg.querySelector(".adr-hover-dot-kospi");'
'var dotD=hg.querySelector(".adr-hover-dot-kosdaq");'
'var pts;try{pts=JSON.parse(row.getAttribute("data-adr-pts")||"[]");}catch(e){pts=[];}'
'if(!pts.length)return;'
'var vb=(row.getAttribute("data-adr-vb")||"360 95").split(/\\s+/);'
'var VW=parseFloat(vb[0])||360,VH=parseFloat(vb[1])||95;'
'var dEl=tip.querySelector(".tip-d");'
'var lineK=tip.querySelector(".tip-kospi");'
'var lineD=tip.querySelector(".tip-kosdaq");'
'var vK=lineK?lineK.querySelector(".tip-v"):null;'
'var vD=lineD?lineD.querySelector(".tip-v"):null;'
'function setDot(dot,pt){'
'if(!dot)return;'
'if(pt){dot.setAttribute("cx",pt.y!=null?pt.x:0);dot.setAttribute("cy",pt.y);dot.setAttribute("opacity","1");}'
'else{dot.setAttribute("opacity","0");}'
'}'
'function setTipLine(lineEl,vEl,pt){'
'if(!lineEl||!vEl)return;'
'if(pt){'
'vEl.textContent=pt.v.toFixed(1);'
'vEl.className="tip-v "+cls(pt.v)+(pt.live?" live":"");'
'lineEl.hidden=false;'
'}else{lineEl.hidden=true;}'
'}'
'function applyPoint(p,save){'
'line.setAttribute("x1",p.x);line.setAttribute("x2",p.x);'
'hg.setAttribute("opacity","1");'
# 점 — 시장별 데이터 있는 쪽만 표시
'var pk=p.kospi||null,pd=p.kosdaq||null;'
'if(pk){dotK.setAttribute("cx",p.x);dotK.setAttribute("cy",pk.y);dotK.setAttribute("opacity","1");}'
'else dotK.setAttribute("opacity","0");'
'if(pd){dotD.setAttribute("cx",p.x);dotD.setAttribute("cy",pd.y);dotD.setAttribute("opacity","1");}'
'else dotD.setAttribute("opacity","0");'
# 날짜 — MM-DD (요일)
'var dt=new Date((p.d||"")+"T00:00:00+09:00");'
'var dow=isNaN(dt.getTime())?"":["","","","","","",""][dt.getDay()];'
'var dlbl=(p.d||"").length>=10?p.d.slice(5):(p.d||"");'
'dEl.textContent=dlbl+(dow?" ("+dow+")":"");'
'setTipLine(lineK,vK,pk);'
'setTipLine(lineD,vD,pd);'
'tip.style.opacity="1";'
'if(save!==false&&p.d)selectedDate=p.d;'
# 위치 — svg 내 x 비율로 변환 후 row(absolute parent) 기준 left 보정.
'requestAnimationFrame(function(){'
'var r=svg.getBoundingClientRect();if(!r.width)return;'
'var rr=row.getBoundingClientRect();'
'var pxX=p.x/VW*r.width+(r.left-rr.left);'
'var tw=tip.offsetWidth;'
'var lx=pxX-tw/2;'
'var maxL=rr.width-tw-4;'
'if(lx<4)lx=4;if(lx>maxL)lx=maxL;'
'tip.style.left=lx+"px";'
# top: 두 점 평균 y 가 차트 상단이면 svg 아래, 하단이면 svg 위.
'var svgTop=r.top-rr.top;'
'var th=tip.offsetHeight;'
'var ys=[];if(pk)ys.push(pk.y);if(pd)ys.push(pd.y);'
'var avgY=ys.length?(ys.reduce(function(a,b){return a+b;},0)/ys.length):VH/2;'
'var pyRatio=avgY/VH;'
'var ty=(pyRatio<0.5)?(svgTop+r.height-th-2):(svgTop-th-2);'
'if(ty<-2)ty=svgTop+r.height-th-2;'
'tip.style.top=ty+"px";'
'});'
'}'
'function moveByPx(cx){'
'var r=svg.getBoundingClientRect();if(!r.width)return;'
'var px=(cx-r.left)/r.width*VW;'
'var bi=0,bd=Math.abs(pts[0].x-px);'
'for(var i=1;i<pts.length;i++){var d=Math.abs(pts[i].x-px);if(d<bd){bd=d;bi=i;}}'
'applyPoint(pts[bi],true);'
'}'
'svg.addEventListener("pointermove",function(e){moveByPx(e.clientX);});'
'svg.addEventListener("pointerdown",function(e){moveByPx(e.clientX);});'
# 영속 복원 — 마지막 선택 날짜가 있고 새 데이터에도 있으면 indicator 재배치.
'if(selectedDate){'
'for(var k=0;k<pts.length;k++){'
'if(pts[k].d===selectedDate){applyPoint(pts[k],false);break;}'
'}'
'}'
'}'
'function scan(){document.querySelectorAll(".adr-trend-row[data-adr-pts]").forEach(init);}'
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",scan);'
'else scan();'
# swap(자동 갱신) 후 새 DOM 들어오면 재바인딩 + 선택 복원.
'new MutationObserver(scan).observe(document.body,{childList:true,subtree:true});'
'})();</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 = (
'<script>(function(){'
'var dailyCache=new Map();'
# minuteCache key = "code:unit:subrange" (예: "005930:5m:1h"). value = {svg, ts}.
'var minuteCache=new Map();'
'var activeRange=new Map();'
# activeSubRange key = "code:unit". value = "30min"|"1h"|"3h"|"5h"|"1d". 기본 "1h".
'var activeSubRange=new Map();'
'var inFlight=new Map();'
'var MINUTE_TTL_MS=30000;'
'var DEFAULT_SUBRANGE="1h";'
'var Q=function(s){return s.replace(/"/g,\'\\\\"\');};'
'function applyActive(scope,range){'
'if(!scope||!range)return;'
'scope.querySelectorAll(".chart-tabs button").forEach(function(b){'
'b.classList.toggle("active",b.getAttribute("data-range")===range);'
'});'
'scope.querySelectorAll(".chart-panel").forEach(function(p){'
'p.classList.toggle("active",p.getAttribute("data-range")===range);'
'});'
'}'
'function applySubActive(panel,subrange){'
'if(!panel||!subrange)return;'
'panel.querySelectorAll(".chart-subtabs button").forEach(function(b){'
'b.classList.toggle("active",b.getAttribute("data-subrange")===subrange);'
'});'
'}'
# 일봉 → daily 복원 직후 activeRange 적용. 활성이 분봉이면 fetchMinute 자동.
'function applyAndMaybeMinute(scope,code){'
'var ar=activeRange.get(code);'
'if(!ar)return;'
'applyActive(scope,ar);'
'if(ar==="5m"||ar==="1m"||ar==="15m"){'
'var panel=scope.querySelector(\'.chart-panel[data-range="\'+Q(ar)+\'"]\');'
'if(panel){'
'var sr=activeSubRange.get(code+":"+ar)||DEFAULT_SUBRANGE;'
'applySubActive(panel,sr);'
'var subpanel=panel.querySelector(".chart-subpanel");'
'if(subpanel)fetchMinute(code,ar,sr,subpanel);'
'}'
'}'
'}'
'function fetchDaily(code,container){'
'if(!code||!container)return null;'
'if(dailyCache.has(code)){'
'container.innerHTML=dailyCache.get(code);'
'applyAndMaybeMinute(container,code);'
'}else if(!container.firstElementChild){'
'container.innerHTML=\'<div class="chart-loading">차트 불러오는 중…</div>\';'
'}'
'var key="d:"+code;'
'if(inFlight.has(key))return inFlight.get(key);'
'var p=fetch("/api/chart_svg?code="+encodeURIComponent(code),{credentials:"same-origin",cache:"no-store"})'
'.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.text();})'
'.then(function(html){'
'dailyCache.set(code,html);'
'document.querySelectorAll(\'.chart-svg[data-chart-code="\'+Q(code)+\'"]\').forEach(function(el){'
'if(el.closest("details[open]")){'
'el.innerHTML=html;'
'applyAndMaybeMinute(el,code);'
'}'
'});'
'})'
'.catch(function(){'
'if(!dailyCache.has(code))container.innerHTML=\'<div class="chart-error">차트 불러오기 실패</div>\';'
'})'
'.finally(function(){inFlight.delete(key);});'
'inFlight.set(key,p);'
'return p;'
'}'
'function fetchMinute(code,unit,subrange,subpanel){'
'if(!code||!unit||!subpanel)return null;'
'var sr=subrange||DEFAULT_SUBRANGE;'
'var key=code+":"+unit+":"+sr;'
'var cached=minuteCache.get(key);'
'var now=Date.now();'
'if(cached){'
'subpanel.innerHTML=cached.svg;'
'if(now-cached.ts<MINUTE_TTL_MS)return null;'
'}else if(!subpanel.firstElementChild){'
'subpanel.innerHTML=\'<div class="chart-loading">분봉 불러오는 중…</div>\';'
'}'
'var inKey="m:"+key;'
'if(inFlight.has(inKey))return inFlight.get(inKey);'
'var url="/api/chart_svg?code="+encodeURIComponent(code)+"&unit="+encodeURIComponent(unit)+"&range="+encodeURIComponent(sr);'
'var p=fetch(url,{credentials:"same-origin",cache:"no-store"})'
'.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.text();})'
'.then(function(svg){'
'minuteCache.set(key,{svg:svg,ts:Date.now()});'
# 같은 종목/유닛 모든 subpanel 갱신 — 단 사용자가 그 subrange 를 현재 보고 있을 때만.
'document.querySelectorAll(\'.chart-svg[data-chart-code="\'+Q(code)+\'"] .chart-panel[data-range="\'+Q(unit)+\'"] .chart-subpanel\').forEach(function(p){'
'var curSr=activeSubRange.get(code+":"+unit)||DEFAULT_SUBRANGE;'
'if(curSr===sr&&p.closest("details[open]"))p.innerHTML=svg;'
'});'
'})'
'.catch(function(){'
'if(!cached)subpanel.innerHTML=\'<div class="chart-error">분봉 불러오기 실패</div>\';'
'})'
'.finally(function(){inFlight.delete(inKey);});'
'inFlight.set(inKey,p);'
'return p;'
'}'
'function loadChartsInOpen(root){'
'(root||document).querySelectorAll("details[open]").forEach(function(d){'
'var c=d.querySelector(".chart-svg[data-chart-code]");'
'if(c)fetchDaily(c.getAttribute("data-chart-code"),c);'
'});'
'}'
# <details> toggle bubble 안 함 → capture=true.
'document.addEventListener("toggle",function(ev){'
'var d=ev.target;'
'if(!d||d.tagName!=="DETAILS"||!d.open)return;'
'var c=d.querySelector(".chart-svg[data-chart-code]");'
'if(c)fetchDaily(c.getAttribute("data-chart-code"),c);'
'},true);'
# crosshair: SVG 안에 overlay group 추가. press-and-hold 시작, 이동 따라다님, 떼면 svg.__drag만 해제(표시 유지).
# panels swap 으로 SVG 가 새 DOM 으로 갈아끼워져도 crosshairState 의 좌표·모드를 새 SVG 에 복원.
'var SVG_NS="http://www.w3.org/2000/svg";'
'var crosshairState=new Map();'
'function _mk(t,a){var e=document.createElementNS(SVG_NS,t);for(var k in a)e.setAttribute(k,a[k]);return e;}'
'function _stateKey(svg){'
'var ch=svg.closest&&svg.closest(".chart-svg");if(!ch)return null;'
'var code=ch.getAttribute("data-chart-code");if(!code)return null;'
'var panel=svg.closest(".chart-panel");if(!panel)return code;'
'var range=panel.getAttribute("data-range")||"";'
'var subpanel=svg.closest(".chart-subpanel");'
'if(subpanel){'
'var sr=(activeSubRange.get(code+":"+range)||DEFAULT_SUBRANGE);'
'return code+":"+range+":"+sr;'
'}'
'return code+":"+range;'
'}'
'function bindCrosshair(svg){'
'if(svg.__xh)return;svg.__xh=true;'
'var meta;try{meta=JSON.parse(svg.getAttribute("data-meta")||"null");}catch(e){return;}'
'if(!meta||!meta.candles||!meta.candles.length)return;'
'var ov=_mk("g",{"class":"crosshair-overlay"});'
'ov.style.display="none";ov.style.pointerEvents="none";'
'var vline=_mk("line",{stroke:"#cfd5df","stroke-width":"1","stroke-dasharray":"4 3",opacity:"0.65"});'
'var hline=_mk("line",{stroke:"#cfd5df","stroke-width":"1","stroke-dasharray":"4 3",opacity:"0.65"});'
# X 모드: 차트 내부 floating OHLC 팝업 (xTipG). Y 모드: hline 왼쪽 끝 가격 라벨 (yTipG).
'var xTipG=_mk("g",{"class":"x-tip"});'
'var xTipBg=_mk("rect",{x:"0",y:"0",width:"320",height:"176",fill:"rgba(15,20,25,0.95)",stroke:"#2a2f3a","stroke-width":"1",rx:"8"});'
'var xTipTxt=_mk("text",{fill:"#cfd5df"});'
'xTipG.appendChild(xTipBg);xTipG.appendChild(xTipTxt);'
'xTipG.style.display="none";'
'var yTipG=_mk("g",{"class":"y-tip"});'
'var yTipBg=_mk("rect",{x:"0",y:"0",width:"152",height:"40",fill:"rgba(15,20,25,0.96)",stroke:"#cfd5df","stroke-width":"1",rx:"6"});'
'var yTipTxt=_mk("text",{fill:"#cfd5df","text-anchor":"middle","dominant-baseline":"central","font-size":"26","font-weight":"700",x:"76",y:"20"});'
'yTipG.appendChild(yTipBg);yTipG.appendChild(yTipTxt);'
'yTipG.style.display="none";'
# 닫기 버튼 — OHLC 팝업(xTipG) 우상단 안쪽에 부착. xTipG transform 따라 함께 이동.
# net-chart-tip 의 .tip-close 와 동일 패턴(팝업 자체 우상단).
# ov 는 pointerEvents:none 이라 closeG 만 auto 로 override 해 클릭 통과.
'var closeG=_mk("g",{"class":"crosshair-close"});'
'var closeBg=_mk("circle",{cx:"0",cy:"0",r:"14",fill:"rgba(15,20,25,0.95)",stroke:"#cfd5df","stroke-width":"1.2"});'
'var closeTxt=_mk("text",{x:"0",y:"1","text-anchor":"middle","dominant-baseline":"central","font-size":"22","font-weight":"700",fill:"#cfd5df"});'
'closeTxt.textContent="×";'
'closeG.appendChild(closeBg);closeG.appendChild(closeTxt);'
'closeG.style.cursor="pointer";'
'closeG.style.pointerEvents="auto";'
# xTipBg 320×176 안의 우상단 모서리. r=14 라 여백 18 두고 중심 (300,20).
'closeG.setAttribute("transform","translate(300,20)");'
'function closeOverlay(ev){'
'if(ev){ev.stopPropagation();if(ev.cancelable)ev.preventDefault();}'
'ov.style.display="none";'
'svg.__drag=false;svg.__mode=null;'
'var k=_stateKey(svg);'
'if(k)crosshairState.delete(k);'
'}'
'closeG.addEventListener("click",closeOverlay);'
'closeG.addEventListener("touchstart",closeOverlay,{passive:false});'
# svg 의 mousedown=start 가 bubble 로 잡지 않도록 closeG 의 mousedown 도 차단.
'closeG.addEventListener("mousedown",function(ev){ev.stopPropagation();});'
'xTipG.appendChild(closeG);'
'ov.appendChild(vline);ov.appendChild(hline);ov.appendChild(xTipG);ov.appendChild(yTipG);'
'svg.appendChild(ov);'
# mode: "x" = 세로선+OHLC, "y" = 가로선+가격. 시작 위치로 결정 후 드래그 중 유지.
'svg.__mode=null;'
'function pt(ev){'
'var r=svg.getBoundingClientRect();'
'var cx=ev.touches?ev.touches[0].clientX:ev.clientX;'
'var cy=ev.touches?ev.touches[0].clientY:ev.clientY;'
'return [(cx-r.left)/r.width*meta.chartW,(cy-r.top)/r.height*meta.chartH];'
'}'
# 시가 대비 색상 — up 빨강, down 파랑 (한국 관습).
'function _col(v,base){return v>base?"#ef4444":(v<base?"#3b82f6":"#cfd5df");}'
'function _mkTspan(attrs,txt,fill){'
'var s=document.createElementNS(SVG_NS,"tspan");'
'for(var k in attrs)s.setAttribute(k,attrs[k]);'
'if(fill)s.setAttribute("fill",fill);'
's.textContent=txt;'
'return s;'
'}'
'function _pctStr(v,base){'
'if(!base)return "";'
'var p=(v-base)/base*100;'
'return (p>=0?"+":"")+p.toFixed(2)+"%";'
'}'
'function _setOhlcText(d,c,xPos){'
'while(xTipTxt.firstChild)xTipTxt.removeChild(xTipTxt.firstChild);'
'var ccol=_col(c.c,c.o),hcol=_col(c.h,c.o),lcol=_col(c.l,c.o);'
# 5행 팝업: 날짜 → 시 → 고 → 저 → 종(강조)
'xTipTxt.appendChild(_mkTspan({x:16,y:28,"font-size":22,"font-weight":500},d,"#8a8f9a"));'
'xTipTxt.appendChild(_mkTspan({x:16,dy:32,"font-size":24,"font-weight":500},""+c.o.toLocaleString()));'
'xTipTxt.appendChild(_mkTspan({x:16,dy:30,"font-size":24,"font-weight":500},""+c.h.toLocaleString()+" ",hcol));'
'xTipTxt.appendChild(_mkTspan({"font-size":24,"font-weight":500},_pctStr(c.h,c.o),hcol));'
'xTipTxt.appendChild(_mkTspan({x:16,dy:30,"font-size":24,"font-weight":500},""+c.l.toLocaleString()+" ",lcol));'
'xTipTxt.appendChild(_mkTspan({"font-size":24,"font-weight":500},_pctStr(c.l,c.o),lcol));'
'xTipTxt.appendChild(_mkTspan({x:16,dy:34,"font-size":28,"font-weight":700},""+c.c.toLocaleString()+" ",ccol));'
'xTipTxt.appendChild(_mkTspan({"font-size":24,"font-weight":700},_pctStr(c.c,c.o),ccol));'
# 팝업 위치 — vline 의 반대 쪽으로 자동 배치 (좌/우). 화면 벗어남 방지 clamp.
'var tipW=320,tipH=176,gap=10;'
'var tipX=xPos+gap;'
'if(tipX+tipW>meta.chartW-meta.padR)tipX=xPos-gap-tipW;'
'if(tipX<meta.padL)tipX=meta.padL+2;'
'var tipY=meta.priceTop+8;'
'xTipG.setAttribute("transform","translate("+tipX+","+tipY+")");'
'}'
'function updateAt(vx,vy,mode){'
'svg.__mode=mode;'
'if(mode==="y"){'
# 가격(가로선) 모드 — 가로선 + 왼쪽 끝 가격 라벨.
'vline.style.display="none";'
'hline.style.display="";'
'xTipG.style.display="none";'
'yTipG.style.display="";'
'var clampY=Math.max(meta.priceTop,Math.min(meta.priceBot,vy));'
'hline.setAttribute("x1",meta.padL);hline.setAttribute("x2",meta.chartW-meta.padR);'
'hline.setAttribute("y1",clampY);hline.setAttribute("y2",clampY);'
'var frac=(meta.priceBot-clampY)/(meta.priceBot-meta.priceTop);'
'var price=meta.ymin+frac*(meta.ymax-meta.ymin);'
'yTipTxt.textContent=Math.round(price).toLocaleString();'
# 가로선 왼쪽 끝 위치 — y는 가로선 중심에 맞춰 ±lblH/2. 차트 안쪽 priceTop~priceBot clamp.
'var lblW=152,lblH=40;'
'var lblX=meta.padL+2;'
'var lblY=clampY-lblH/2;'
'if(lblY<meta.priceTop)lblY=meta.priceTop;'
'if(lblY+lblH>meta.priceBot)lblY=meta.priceBot-lblH;'
'yTipG.setAttribute("transform","translate("+lblX+","+lblY+")");'
'}else{'
# X(봉) 모드 — 세로선 + 차트내 OHLC 팝업.
'vline.style.display="";'
'hline.style.display="none";'
'yTipG.style.display="none";'
'xTipG.style.display="";'
'var n=meta.candles.length;'
'var plotW=meta.chartW-meta.padL-meta.padR;'
'var slot=plotW/n;'
'var i=Math.floor((vx-meta.padL)/slot);'
'if(i<0)i=0;if(i>=n)i=n-1;'
'var c=meta.candles[i];'
'var xPos=meta.padL+slot*(i+0.5);'
'vline.setAttribute("x1",xPos);vline.setAttribute("x2",xPos);'
'vline.setAttribute("y1",meta.priceTop);vline.setAttribute("y2",meta.volBot);'
'var d=c.d;'
'if(d&&d.length===8)d=d.slice(0,4)+"-"+d.slice(4,6)+"-"+d.slice(6,8);'
'_setOhlcText(d,c,xPos);'
'}'
'ov.style.display="";'
# panels swap 후 새 SVG 에 복원할 좌표·모드 저장.
'var key=_stateKey(svg);'
'if(key)crosshairState.set(key,{mode:mode,vx:vx,vy:vy});'
'}'
'function update(ev){'
'var p=pt(ev);'
'updateAt(p[0],p[1],svg.__mode);'
'}'
'function start(ev){'
'var p=pt(ev);'
# 우측 Y축 라벨 영역(가격 라벨 자리) 누르면 Y(가격) 모드, 그 외는 X(봉) 모드.
'svg.__mode=(p[0]>meta.chartW-meta.padR)?"y":"x";'
'svg.__drag=true;'
'updateAt(p[0],p[1],svg.__mode);'
'ev.preventDefault();'
'}'
'function moveIfDrag(ev){if(svg.__drag)update(ev);}'
# 손가락 떼도 overlay 유지 — drag flag 만 해제, 표시는 다음 누름 시 갱신.
'function endFn(){svg.__drag=false;}'
# panels swap 직후 새 SVG 에 이전 좌표·모드 복원.
'var _sk=_stateKey(svg);'
'if(_sk){'
'var _st=crosshairState.get(_sk);'
'if(_st)updateAt(_st.vx,_st.vy,_st.mode);'
'}'
'svg.addEventListener("mousedown",start);'
'svg.addEventListener("touchstart",start,{passive:false});'
# window 레벨 listen — 드래그가 SVG 영역 밖으로 벗어나도 떼지 않으면 유지.
'window.addEventListener("mousemove",moveIfDrag);'
'window.addEventListener("touchmove",function(ev){if(svg.__drag){update(ev);ev.preventDefault();}},{passive:false});'
'window.addEventListener("mouseup",endFn);'
'window.addEventListener("touchend",endFn);'
'window.addEventListener("touchcancel",endFn);'
'}'
'function bindAllCharts(root){'
'(root||document).querySelectorAll(".chart-svg svg[data-meta]").forEach(bindCrosshair);'
'}'
# MutationObserver — innerHTML 으로 들어오는 SVG 자동 바인딩.
'new MutationObserver(function(ms){ms.forEach(function(m){m.addedNodes.forEach(function(n){if(n.nodeType===1)bindAllCharts(n);});});}).observe(document.body,{childList:true,subtree:true});'
# apply() swap 완료 이벤트 — 열린 카드 daily 복원 + activeRange 분봉 자동 갱신.
'document.addEventListener("behive:panels-loaded",function(){loadChartsInOpen();});'
# chart-tabs 클릭 — activeRange 기록 + 패널 토글 + 분봉이면 lazy fetch.
'document.addEventListener("click",function(ev){'
'var btn=ev.target.closest(".chart-svg .chart-tabs button[data-range]");'
'if(!btn)return;'
'var scope=btn.closest(".chart-svg");'
'if(!scope)return;'
'var code=scope.getAttribute("data-chart-code");'
'var range=btn.getAttribute("data-range");'
'var unit=btn.getAttribute("data-unit");'
'if(code)activeRange.set(code,range);'
'applyActive(scope,range);'
'if(unit&&code){'
'var panel=scope.querySelector(\'.chart-panel[data-range="\'+Q(range)+\'"]\');'
'if(panel){'
'var sr=activeSubRange.get(code+":"+unit)||DEFAULT_SUBRANGE;'
'applySubActive(panel,sr);'
'var subpanel=panel.querySelector(".chart-subpanel");'
'if(subpanel)fetchMinute(code,unit,sr,subpanel);'
'}'
'}'
'},false);'
# chart-subtabs 클릭 — subrange 기록 + fetch.
'document.addEventListener("click",function(ev){'
'var btn=ev.target.closest(".chart-svg .chart-subtabs button[data-subrange]");'
'if(!btn)return;'
'var panel=btn.closest(".chart-panel[data-unit]");'
'var scope=btn.closest(".chart-svg");'
'if(!panel||!scope)return;'
'var code=scope.getAttribute("data-chart-code");'
'var unit=panel.getAttribute("data-unit");'
'var sr=btn.getAttribute("data-subrange");'
'if(!code||!unit||!sr)return;'
'activeSubRange.set(code+":"+unit,sr);'
'applySubActive(panel,sr);'
'var subpanel=panel.querySelector(".chart-subpanel");'
'if(subpanel)fetchMinute(code,unit,sr,subpanel);'
'},false);'
# 첫 페이지 로드 시 이미 열려있는 카드 (드물지만 안전).
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){loadChartsInOpen();});'
'else loadChartsInOpen();'
'})();</script>'
)
# pull-to-refresh: 임계 넘으면 fetch(__behive_load) + spinner 유지, 완료 후 reset. reload() 안 함.
# iOS PWA엔 네이티브 PTR 부재라 직접 구현. resistance 0.55로 손맛.
ptr_script = (
'<script>(function(){'
'var c=document.getElementById("page"),p=document.getElementById("ptr");if(!c||!p)return;'
'var sy=0,dy=0,pull=false,ok=false,T=70,MAX=110;'
# dragging 클래스 해제는 반드시 transform 클리어와 동시에. 분리되면 sticky topbar가 .page transform 좌표계에 갇혀 스크롤 따라옴.
'function reset(){c.classList.remove("dragging");c.style.transform="";p.style.transform="";p.style.opacity="";p.classList.remove("committed","spinning");}'
'document.addEventListener("touchstart",function(e){'
'if(window.scrollY<=0&&e.touches.length===1){'
'sy=e.touches[0].clientY;dy=0;pull=true;ok=false;'
'c.classList.add("dragging");'
'}},{passive:true});'
'document.addEventListener("touchmove",function(e){'
'if(!pull)return;dy=e.touches[0].clientY-sy;'
'if(dy<=0){reset();return;}'
'var d=dy*0.55;if(d>MAX)d=MAX;'
'var pct=Math.min(d/T,1),r=pct*180;'
'c.style.transform="translateY("+d+"px)";'
'p.style.transform="rotate("+r+"deg)";'
'p.style.opacity=Math.min(d/25,1);'
'if(d>T){if(!ok){ok=true;p.classList.add("committed");}}'
'else if(ok){ok=false;p.classList.remove("committed");}'
'},{passive:true});'
'document.addEventListener("touchend",function(){'
'if(!pull)return;'
'var d=dy*0.55;if(d>MAX)d=MAX;'
'if(d>T){'
'c.style.transform="translateY("+(T+15)+"px)";'
'p.style.transform="";'
'p.style.opacity="1";'
'p.classList.add("spinning");'
'var done=function(){'
'reset();'
'if(window.__behive_auto_arm)window.__behive_auto_arm();'
'};'
'var ow=window.__behive_active_owner?window.__behive_active_owner():null;'
'var f=window.__behive_load?window.__behive_load(ow):Promise.resolve();'
'f.then(done,done);'
'}else{reset();}'
'pull=false;dy=0;'
'});'
'})();</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 = (
'<script>(function(){'
f'var O=[{auto_refresh_tids_js}];'
f'var MARKET={market_active_js};'
'var INT=[0,10,3],TICK=1000,KEY="behive.autoreload",t=null,remain=0;'
# 장 닫힘 동안 시장 열림 감지용 저속 폴링. visible 일 때만 동작.
'var SLOW_MS=60000,slow=null;'
'var btn=document.getElementById("auto-toggle");if(!btn)return;'
'var lbl=btn.querySelector("span:last-child");'
'function mode(){var v=parseInt(localStorage.getItem(KEY)||"0",10);return (v>=0&&v<INT.length)?v:0;}'
'function secs(){return INT[mode()];}'
'function idleLbl(){var s=secs();return s>0?("자동 "+s+"s"):"자동";}'
'function active(){return document.documentElement.getAttribute("data-tab")||"";}'
'function marketNow(){return (typeof window.__behive_market_active!=="undefined")?window.__behive_market_active:MARKET;}'
'function ok(){return marketNow()&&mode()>0&&document.visibilityState==="visible"&&O.indexOf(active())>=0;}'
'function setLbl(s){if(lbl)lbl.textContent=s;}'
'function idleClosedLbl(){return marketNow()?idleLbl():"장마감";}'
'function stop(){if(t){clearInterval(t);t=null;}}'
'function stopSlow(){if(slow){clearInterval(slow);slow=null;}}'
'function startSlow(){'
'stopSlow();'
'slow=setInterval(function(){'
'if(marketNow()){stopSlow();return;}'
'if(document.visibilityState!=="visible")return;'
'if(window.__behive_load)window.__behive_load();'
'},SLOW_MS);'
'}'
'function arm(){'
'stop();'
'if(marketNow())stopSlow();else startSlow();'
'if(!ok()){setLbl(idleClosedLbl());return;}'
'remain=secs();setLbl("자동 "+remain+"s");'
't=setInterval(function(){'
'if(!ok()){stop();setLbl(idleClosedLbl());return;}'
# 차트 툴팁 떠 있는 동안 자동 갱신 미룸 — swap으로 사라지는 거 방지.
'var tt=document.querySelectorAll(".net-chart-tip");'
'for(var ti=0;ti<tt.length;ti++){if(tt[ti].style.opacity==="1")return;}'
# select 드롭다운 열림 상태(=focus)에서도 갱신 미룸 — 사용자가 옵션 고르는 중.
'var ae=document.activeElement;'
'if(ae&&ae.tagName==="SELECT")return;'
'remain-=1;'
'if(remain<=0){'
'stop();'
'var a=active();'
'var ow=(a&&a.indexOf("tab-")===0)?a.slice(4):null;'
'var p=window.__behive_load?window.__behive_load(ow):Promise.resolve();'
'p.then(arm,arm);'
'return;'
'}'
'setLbl("자동 "+remain+"s");'
'},TICK);'
'}'
'function setUI(m){btn.setAttribute("aria-pressed",m>0?"true":"false");}'
'function set(m){localStorage.setItem(KEY,String(m));setUI(m);arm();}'
'btn.addEventListener("click",function(){'
'if(!marketNow())return;' # 장 닫힘 중엔 토글 무시 (button disabled 안전망)
'set((mode()+1)%INT.length);'
'});'
'window.__behive_auto_arm=arm;'
'document.addEventListener("visibilitychange",arm);'
'addEventListener("hashchange",arm);'
# 첫 진입 시 휴장·주말·장외이면 button에 closed/disabled 즉시 적용 — panels fetch 전에도 시각 일관.
'if(window.__behive_set_market)window.__behive_set_market(MARKET);'
'setUI(mode());arm();'
'})();</script>'
)
# 더블탭 zoom 차단 — iOS Safari가 `touch-action: manipulation`을 무시할 때 폴백.
# 300ms 안에 두 번째 touchend가 들어오면 preventDefault → 시스템 zoom 동작·동시 발생하는 ghost click 둘 다 억제.
# 첫 탭의 click은 정상 dispatch되므로 details 토글·새로고침 버튼 등 단일 탭 UX 영향 없음.
nodbltap_script = (
'<script>(function(){'
'var lastT=0;'
'document.addEventListener("touchend",function(e){'
'var now=Date.now();'
'if(now-lastT<=300)e.preventDefault();'
'lastT=now;'
'},{passive:false});'
'})();</script>'
)
idx_info_modal_html = (
'<div id="idx-info-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="idx-info-modal-title">'
'<div class="modal-overlay" data-modal-close="1"></div>'
'<div class="modal-box" role="document">'
'<div class="modal-head">'
'<div class="modal-title" id="idx-info-modal-title" data-idx-modal-title>지수 설명</div>'
'<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
'</div>'
'<div class="modal-body idx-info-body">'
'<div class="idx-info-stats">'
'<span class="idx-info-price" data-idx-modal-price>—</span>'
'<span class="idx-info-change" data-idx-modal-change>—</span>'
'</div>'
'<div class="idx-info-chart" data-idx-modal-chart hidden></div>'
'<div class="idx-info-desc" data-idx-modal-body>—</div>'
'</div>'
'<div class="modal-actions">'
'<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
'</div>'
'</div>'
'</div>'
)
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 = (
'<script>(function(){'
'var __savedScrollY=0;'
'function lockScroll(){'
'if(document.body.classList.contains("modal-open"))return;'
'__savedScrollY=window.scrollY||window.pageYOffset||0;'
'document.body.style.top=(-__savedScrollY)+"px";'
'document.body.classList.add("modal-open");'
'}'
'function unlockScroll(){'
'if(document.querySelector(".modal:not(.hidden)"))return;'
'document.body.classList.remove("modal-open");'
'document.body.style.top="";'
'window.scrollTo(0,__savedScrollY);'
'}'
'window.__behiveLockScroll=lockScroll;window.__behiveUnlockScroll=unlockScroll;'
'function openModal(id){var m=document.getElementById(id);if(!m)return;'
'm.classList.remove("hidden");m.setAttribute("aria-hidden","false");'
'lockScroll();'
'var f=m.querySelector("input[name=stock]");if(f)setTimeout(function(){f.focus();},30);'
'}'
'function closeModal(m){if(!m)return;'
'm.classList.add("hidden");m.setAttribute("aria-hidden","true");'
'clearFeedback(m);'
'unlockScroll();'
'}'
'function clearFeedback(m){var fb=m&&m.querySelector("[data-feedback]");if(!fb)return;'
'fb.hidden=true;fb.innerHTML="";'
'}'
'function showError(m,msg){var fb=m&&m.querySelector("[data-feedback]");'
'if(!fb){alert(msg);return;}' # delete form은 modal 밖이라 fb 없음 — alert로 fallback
'fb.hidden=false;fb.innerHTML=\'<div class="feedback-error"></div>\';'
'fb.firstChild.textContent=msg;'
'}'
'function openCandidates(query,cands){'
'var cm=document.getElementById("candidate-modal");if(!cm)return;'
'var title=cm.querySelector("[data-cand-title]");'
'if(title)title.textContent=\'"\'+query+\'"와 일치하는 종목이 여러 개입니다. 선택해주세요.\';'
'var list=cm.querySelector("[data-cand-list]");'
'if(list){'
'list.innerHTML="";'
'cands.forEach(function(c){'
'var btn=document.createElement("button");'
'btn.type="button";'
'btn.className="candidate-btn";'
'btn.setAttribute("data-cand-name",c.name||"");'
'btn.setAttribute("data-cand-code",c.code||"");'
'var lbl=document.createElement("span");lbl.textContent=c.name||"";'
'var code=document.createElement("span");code.className="cand-code";code.textContent=c.code||"";'
'btn.appendChild(lbl);btn.appendChild(code);'
'list.appendChild(btn);'
'});'
'}'
'openModal("candidate-modal");'
'}'
'function submitForm(form,overrideName,overrideCode,keepOpen){'
'var m=form.closest(".modal");'
'clearFeedback(m);'
'var fd=new FormData(form);'
'if(overrideName)fd.set("stock",overrideName);'
'if(overrideCode)fd.set("code",overrideCode);'
'var data=new URLSearchParams();'
'fd.forEach(function(v,k){data.append(k,v);});'
'var submitBtn=form.querySelector(\'button[type="submit"]\');'
'if(submitBtn)submitBtn.disabled=true;'
'fetch(form.action,{method:"POST",headers:{"Accept":"application/json","Content-Type":"application/x-www-form-urlencoded"},body:data.toString(),credentials:"same-origin"})'
'.then(function(r){return r.json().catch(function(){return {ok:false,error:"HTTP "+r.status};});})'
'.then(function(j){'
'if(j&&j.ok){'
'if(!keepOpen){'
'form.reset();'
'closeModal(m);'
'}'
'closeModal(document.getElementById("candidate-modal"));'
'if(window.__behive_load)window.__behive_load(null,true);'
'}else if(j&&j.error==="multiple"){'
'openCandidates(j.query||"",j.candidates||[]);'
'}else{'
'showError(m,(j&&j.error)||"추가 실패");'
'}'
'})'
'.catch(function(){showError(m,"네트워크 오류");})'
'.finally(function(){if(submitBtn)submitBtn.disabled=false;});'
'}'
'function escHtml(s){return String(s==null?"":s).replace(/[&<>"\\\']/g,function(c){'
'return {"&":"&amp;","<":"&lt;",">":"&gt;","\\"":"&quot;","\\\'":"&#39;"}[c];'
'});}'
'function fmtInt(n){return (n==null?0:n).toLocaleString();}'
'function fmtTradeDate(s){'
'var m=String(s==null?"":s).match(/^\\d{2}(\\d{2})-(\\d{1,2})-(\\d{1,2})$/);'
'return m?(m[1]+"/"+parseInt(m[2],10)+"/"+parseInt(m[3],10)):(s||"");'
'}'
'function setupTradePagers(root){'
'root.querySelectorAll(".trade-pager-wrap").forEach(function(wrap){'
'var listEl=wrap.querySelector(".trade-list");'
'var realRows=listEl?Array.prototype.slice.call(listEl.querySelectorAll(":scope > li")):[];'
# 종목명 헤더(ti-stock-line)가 붙은 owner 모드는 행 높이가 3줄로 늘어나 8개면 스크롤. 5개로 축소.
'var hasStockLine=listEl && listEl.querySelector(".ti-stock-line")!==null;'
'var PAGE_SIZE=hasStockLine?5:8;'
'var totalReal=realRows.length;'
'var totalPages=Math.max(1,Math.ceil(totalReal/PAGE_SIZE));'
'var padNeeded=Math.max(0,totalPages*PAGE_SIZE-totalReal);'
'for(var pi=0;pi<padNeeded && listEl;pi++){'
'var ph=document.createElement("li");'
'ph.className="trade-item placeholder";'
'ph.innerHTML=\'<div class="ti-row1">&nbsp;</div><div class="ti-row2">&nbsp;</div>\';'
'listEl.appendChild(ph);'
'}'
'var rows=listEl?Array.prototype.slice.call(listEl.querySelectorAll(":scope > li")):[];'
'var total=totalPages;'
'var ind=wrap.querySelector(".pg-indicator");'
'var prev=wrap.querySelector(\'[data-pg="prev"]\');'
'var next=wrap.querySelector(\'[data-pg="next"]\');'
'var page=0;'
'function render(){'
'for(var i=0;i<rows.length;i++){'
'rows[i].style.display=(i>=page*PAGE_SIZE && i<(page+1)*PAGE_SIZE)?"":"none";'
'}'
'if(ind)ind.textContent=(page+1)+" / "+total;'
'if(prev)prev.disabled=page<=0;'
'if(next)next.disabled=page>=total-1;'
'}'
'if(rows.length<=PAGE_SIZE){'
'var pg=wrap.querySelector(".trade-pager");'
'if(pg)pg.style.display="none";'
'}'
'if(prev)prev.addEventListener("click",function(e){e.preventDefault();e.stopPropagation();if(page>0){page--;render();}});'
'if(next)next.addEventListener("click",function(e){e.preventDefault();e.stopPropagation();if(page<total-1){page++;render();}});'
'render();'
'});'
'}'
'function fmtNum(n,d){if(n==null||n==="")return "";var v=Number(n);if(isNaN(v))return "";return v.toLocaleString(undefined,{minimumFractionDigits:d,maximumFractionDigits:d});}'
'var INFO_DESC={'
'"현재가":"지금 시장에서 거래되는 주가입니다.",'
'"시가총액":"회사 전체의 시장 가치. 현재가 × 상장주식수로, 회사 크기를 가늠하는 기준입니다.",'
'"PER":"주가가 회사 1년 이익의 몇 배인지. 낮을수록 싼 편입니다.",'
'"PBR":"주가가 회사 순자산의 몇 배인지. 1배 미만이면 자산보다 싸게 거래되는 셈입니다.",'
'"EPS":"주식 한 주가 1년 동안 벌어들인 이익입니다.",'
'"ROE":"회사가 가진 돈으로 얼마나 이익을 냈는지. 높을수록 좋습니다.",'
'"영업이익":"회사가 본업으로 번 이익입니다. 괄호는 해당 분기(최근 확정 분기 기준)이며, 분기 데이터가 없으면 연간으로 표시됩니다.",'
'"유통비율":"전체 주식 중 시장에서 실제 사고팔 수 있는 비율. 대주주·자사주처럼 묶인 물량은 빼고 봅니다.",'
'"52주 최고/최저":"최근 1년간 가장 높았던 주가와 낮았던 주가입니다.",'
'"상장주식수":"시장에 나와 있는 전체 주식 수입니다.",'
'"외국인보유":"전체 주식 중 외국인이 가진 비율입니다.",'
'"매출증가율":"직전 연도 대비 매출액이 얼마나 늘었는지(전년대비). 출처 FnGuide.",'
'"영업이익증가율":"직전 연도 대비 영업이익 증가율(전년대비). 출처 FnGuide.",'
'"EPS증가율":"직전 연도 대비 주당순이익 증가율(전년대비). 출처 FnGuide.",'
'"목표주가":"증권사들이 제시한 목표주가의 컨센서스(평균). 출처 FnGuide.",'
'"투자의견":"증권사 컨센서스 투자의견. 1(매도)~5(적극매수) 척도. 출처 FnGuide.",'
'"추정PER":"증권사 추정(미래) 이익 기준 PER. 괄호 안 현재 PER보다 낮으면 앞으로 이익이 늘 거란 기대예요. 출처 FnGuide.",'
'"목표가 리비전":"최근 3개월간 목표주가 컨센서스가 상향(+)됐는지 하향(−)됐는지. 출처 WISEreport.",'
'"추정치 리비전":"최근 3개월간 추정 실적치 컨센서스 변화율. 상향이면 이익 전망이 좋아지는 중. 출처 WISEreport.",'
'"어닝 서프라이즈":"최신 확정연도 실적이 직전 컨센서스 대비 얼마나 상회(+)/하회(−)했는지. 출처 WISEreport."'
'};'
'function openInfoDesc(term){'
'var m=document.getElementById("info-desc-modal");if(!m)return;'
'var t=m.querySelector("[data-desc-term]");if(t)t.textContent=term;'
'var b=m.querySelector("[data-desc-body]");if(b)b.textContent=(INFO_DESC[term]||"설명이 없습니다.");'
'm.classList.remove("hidden");m.setAttribute("aria-hidden","false");'
'lockScroll();'
'}'
'function infoWon(n){return fmtInt(n)+"";}'
# 키움이 억원 단위로 주는 금액 → 1조=10,000억 환산. 1조 미만(음수 포함)은 억원, 1조 이상은 조.
'function capDisp(eok){var v=Number(eok);if(isNaN(v))return "";return Math.abs(v)>=10000?(fmtNum(v/10000,2)+"조원"):(fmtInt(v)+"억원");}'
# 증가율(%) — 빨강=증가/파랑=감소 (국내 관행). null이면 dash.
'function growthDisp(v){if(v==null||v==="")return "";var n=Number(v);if(isNaN(n))return "";var col=n>0?"#ef4444":(n<0?"#3b82f6":"#9ca3af");return \'<span style="color:\'+col+\';font-weight:700">\'+(n>0?"+":"")+n.toFixed(1)+"%</span>";}'
# 비교 지표 스펙. cmpOnly=true 는 비교표에서만(단일 상세 제외). fn(j)→표시문자열.
'var INFO_SPECS=['
'{k:"현재가",d:"현재가",sec:"가치지표",cmpOnly:true,fn:function(j){return infoWon(j.price);}},'
'{k:"시가총액",d:"시가총액",sec:"가치지표",cmpOnly:true,fn:function(j){return capDisp(j.market_cap);}},'
'{k:"PER",d:"PER",sec:"가치지표",fn:function(j){return fmtNum(j.per,2);}},'
'{k:"PBR",d:"PBR",sec:"가치지표",fn:function(j){return fmtNum(j.pbr,2);}},'
'{k:"EPS",d:"EPS",sec:"가치지표",fn:function(j){return infoWon(j.eps);}},'
'{k:"ROE",d:"ROE",sec:"가치지표",fn:function(j){return fmtNum(j.roe,2)+"%";}},'
'{k:"영업이익",d:"영업이익",sec:"가치지표",fn:function(j){'
'if(j.op_profit_q!=null)return capDisp(j.op_profit_q)+(j.op_profit_q_period?\' <span class="muted small">(\'+escHtml(j.op_profit_q_period)+\')</span>\':"");'
'return capDisp(j.op_profit)+\' <span class="muted small">(연간)</span>\';'
'}},'
'{k:"매출증가율",d:"매출증가율",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.fundamentals&&j.fundamentals.growth?j.fundamentals.growth.sales_yoy:null);}},'
'{k:"영업이익증가율",d:"영업이익증가율",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.fundamentals&&j.fundamentals.growth?j.fundamentals.growth.oper_profit_yoy:null);}},'
'{k:"EPS증가율",d:"EPS증가율",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.fundamentals&&j.fundamentals.growth?j.fundamentals.growth.eps_yoy:null);}},'
'{k:"목표주가",d:"목표주가",sec:"성장성·컨센서스",fn:function(j){var c=j.fundamentals&&j.fundamentals.consensus;return (c&&c.target_price!=null)?infoWon(c.target_price):"";}},'
'{k:"투자의견",d:"투자의견",sec:"성장성·컨센서스",fn:function(j){var c=j.fundamentals&&j.fundamentals.consensus;return (c&&c.opinion_label)?(escHtml(c.opinion_label)+(c.opinion!=null?\' <span class="muted small">(\'+c.opinion+\'/5)</span>\':"")):"";}},'
'{k:"추정PER",d:"추정PER",sec:"성장성·컨센서스",fn:function(j){var c=j.fundamentals&&j.fundamentals.consensus;if(!(c&&c.per!=null))return "";var pv=Number(j.per);var cur=(j.per!=null&&j.per!==""&&!isNaN(pv))?(\' <span style="color:#7a8493;font-size:11px">(현재 \'+fmtNum(j.per,2)+\'배)</span>\'):"";return fmtNum(c.per,2)+""+cur;}},'
'{k:"목표가 리비전(3M)",d:"목표가 리비전",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.consensus?j.consensus.target_change_pct:null);}},'
'{k:"추정치 리비전(3M)",d:"추정치 리비전",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.consensus?j.consensus.est_change_pct:null);}},'
'{k:"매출 서프라이즈",d:"어닝 서프라이즈",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.consensus?j.consensus.surprise_sales:null);}},'
'{k:"영익 서프라이즈",d:"어닝 서프라이즈",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.consensus?j.consensus.surprise_op:null);}},'
'{k:"상장주식수",d:"상장주식수",sec:"기본정보",fn:function(j){return fmtInt(j.listed_shares)+"";}},'
'{k:"유통비율",d:"유통비율",sec:"기본정보",fn:function(j){return fmtNum(j.dist_rate,2)+"%";}},'
'{k:"외국인보유",d:"외국인보유",sec:"기본정보",fn:function(j){return fmtNum(j.foreign_holding_pct,2)+"%";}},'
'{k:"52주 최고",d:"52주 최고/최저",sec:"기본정보",fn:function(j){return infoWon(j.high_52w)+(j.high_52w_dt?\' <span class="muted small">(\'+escHtml(j.high_52w_dt)+\')</span>\':"");}},'
'{k:"52주 최저",d:"52주 최고/최저",sec:"기본정보",fn:function(j){return infoWon(j.low_52w)+(j.low_52w_dt?\' <span class="muted small">(\'+escHtml(j.low_52w_dt)+\')</span>\':"");}}'
'];'
'function infoQbtn(d){return d?(\' <button type="button" class="info-q" data-info-desc="\'+escHtml(d)+\'" aria-label="\'+escHtml(d)+\' 설명">?</button>\'):"";}'
# CMP: 비교 상태. items=[{code,name}] (index 0 = 기준), data=종목별 stock_info 캐시.
'var CMP={items:[],data:{},symbolsLoaded:false};'
'function infoBodyEl(){var m=document.getElementById("info-modal");return m?m.querySelector("[data-info-body]"):null;}'
# 단일 상세 — 기존 세로 레이아웃 (현재가·시총 제외).
# 최근 증권사 리포트 블록 (단일 종목 뷰 전용). 목표가 상향=▲빨강/하향=▼파랑.
'function infoReportsHtml(j){'
'var rs=j.reports;if(!rs||!rs.length)return "";'
'var h=\'<div class="info-sec-title">📰 최근 증권사 리포트 <span style="color:#5b6473;font-weight:normal;font-size:11px">(목표가·요약, 출처 WISEreport)</span></div>\';'
'rs.forEach(function(r){'
'var arrow="";'
'if(r.target_action&&r.target_action.indexOf("상향")>=0)arrow=\' <span style="color:#ef4444">▲</span>\';'
'else if(r.target_action&&r.target_action.indexOf("하향")>=0)arrow=\' <span style="color:#3b82f6">▼</span>\';'
'var tgt=r.target?(\'<span style="color:#fbbf24;font-weight:600">목표 \'+escHtml(r.target)+arrow+\'</span>\'):"";'
'var rec=r.recomm?(\'<span style="color:#9aa4b2">\'+escHtml(r.recomm)+\'</span>\'):"";'
'var anl=r.analyst?(\'<span style="color:#5b6473;font-size:11px">\'+escHtml(r.analyst)+\'</span>\'):"";'
'var sum="";if(r.summary&&r.summary.length){sum=\'<div style="font-size:11px;color:#7a8493;line-height:1.5;margin-top:3px">▶ \'+r.summary.slice(0,2).map(escHtml).join("<br>▶ ")+\'</div>\';}'
'h+=\'<div style="padding:9px 0;border-bottom:1px solid #1f2937">\'+'
'\'<div style="display:flex;gap:7px;align-items:center;flex-wrap:wrap;font-size:12px"><span style="color:#7a8493">\'+escHtml(r.date||"")+\'</span><b style="color:#cfd5df">\'+escHtml(r.broker||"")+\'</b>\'+tgt+rec+anl+\'</div>\'+'
'\'<div style="font-size:13px;color:#fff;margin:3px 0 0">\'+escHtml(r.title||"")+\'</div>\'+sum+\'</div>\';'
'});'
'return h;'
'}'
'function renderSingleInfo(b,j){'
'function row(k,v,d){return \'<dt>\'+escHtml(k)+infoQbtn(d)+\'</dt><dd>\'+v+\'</dd>\';}'
# 섹션별 info-grid 생성 (해당 sec에 항목 없으면 빈 문자열)
'function secGrid(sec){var g=\'<dl class="info-grid">\',n=0;INFO_SPECS.forEach(function(s){if(s.sec===sec&&!s.cmpOnly){g+=row(s.k,s.fn(j),s.d);n++;}});return n?(g+\'</dl>\'):"";}'
'function titled(t,inner){return inner?(\'<div class="info-sec-title">\'+t+\'</div>\'+inner):"";}'
# 요약 탭 — 현재가·목표주가·핵심 시그널·최신 리포트 1건
'function sumPanel(){'
'var fg=j.fundamentals||{},gr=fg.growth||{},fc=fg.consensus||{},cn=j.consensus||{};'
'var chg=(j.change>0?"#ef4444":(j.change<0?"#3b82f6":"#9ca3af"));'
'var pc=(j.change_pct!=null?(\' <span style="color:\'+chg+\';font-size:12px">(\'+(j.change_pct>0?"+":"")+fmtNum(j.change_pct,2)+\'%)</span>\'):"");'
'var h=\'<dl class="info-grid">\';'
'h+=row("현재가",\'<span style="color:\'+chg+\';font-weight:700">\'+infoWon(j.price)+\'</span>\'+pc,"현재가");'
'h+=row("시가총액",capDisp(j.market_cap),"시가총액");'
'if(fc.target_price!=null)h+=row("목표주가",\'<span style="color:#fbbf24;font-weight:700">\'+infoWon(fc.target_price)+\'</span>\'+(fc.opinion_label?\' <span style="color:#9aa4b2;font-size:12px">\'+escHtml(fc.opinion_label)+\'</span>\':""),"목표주가");'
'if(cn.target_change_pct!=null)h+=row("목표가 리비전(3M)",growthDisp(cn.target_change_pct),"목표가 리비전");'
'if(gr.sales_yoy!=null)h+=row("매출증가율",growthDisp(gr.sales_yoy),"매출증가율");'
'if(gr.eps_yoy!=null)h+=row("EPS증가율",growthDisp(gr.eps_yoy),"EPS증가율");'
'if(cn.surprise_sales!=null)h+=row("매출 서프라이즈",growthDisp(cn.surprise_sales),"어닝 서프라이즈");'
'h+=\'</dl>\';'
'if(j.reports&&j.reports.length){var r=j.reports[0];var ar="";if(r.target_action&&r.target_action.indexOf("상향")>=0)ar=\' <span style="color:#ef4444">▲</span>\';else if(r.target_action&&r.target_action.indexOf("하향")>=0)ar=\' <span style="color:#3b82f6">▼</span>\';h+=\'<div class="info-sec-title">최신 리포트</div><div style="font-size:12px;color:#9aa4b2"><span style="color:#7a8493">\'+escHtml(r.date||"")+\' \'+escHtml(r.broker||"")+\'</span> \'+(r.target?\'<span style="color:#fbbf24">목표 \'+escHtml(r.target)+ar+\'</span>\':"")+\'</div><div style="font-size:13px;color:#fff;margin-top:2px">\'+escHtml(r.title||"")+\'</div>\';}'
'return h;'
'}'
'var companyInner=titled("가치지표",secGrid("가치지표"))+titled("기본정보",secGrid("기본정보"));'
'var growthInner=secGrid("성장성·컨센서스");'
'var repInner=infoReportsHtml(j);'
'var TB=[["summary","요약",sumPanel()],["company","기업정보·가치",companyInner],["growth","성장성·컨센서스",growthInner],["reports","투자리포트",repInner]];'
'var bar=\'<div class="info-tabs" role="tablist">\';var pans="";'
'TB.forEach(function(t,i){'
'bar+=\'<button type="button" class="info-tabbtn\'+(i===0?" active":"")+\'" data-info-tab="\'+t[0]+\'">\'+t[1]+\'</button>\';'
'pans+=\'<div class="info-tabpanel" data-info-panel="\'+t[0]+\'"\'+(i===0?"":" hidden")+\'>\'+(t[2]||\'<div class="muted small" style="padding:12px 0;color:#7a8493">표시할 데이터가 없어요.</div>\')+\'</div>\';'
'});'
'if(b)b.innerHTML=bar+\'</div>\'+pans;'
'}'
# 모달 탭 전환 (data-info-tab/panel) — info-body 안으로 스코프, 자산탭 sub-tab과 독립.
'document.addEventListener("click",function(e){'
'var bt=e.target.closest&&e.target.closest("[data-info-tab]");if(!bt)return;'
'var wrap=bt.closest("[data-info-body]");if(!wrap)return;'
'var key=bt.getAttribute("data-info-tab");'
'wrap.querySelectorAll("[data-info-tab]").forEach(function(x){x.classList.toggle("active",x===bt);});'
'wrap.querySelectorAll("[data-info-panel]").forEach(function(p){p.hidden=(p.getAttribute("data-info-panel")!==key);});'
'});'
# 비교표 — 행=지표(현재가·시총 포함), 열=종목. 첫 열(기준) 외엔 × 제거 버튼.
'function renderCompareInfo(b){'
'var its=CMP.items;'
'var head=\'<th class="info-cmp-label"></th>\';'
'its.forEach(function(it,idx){'
'var rm=idx>0?(\'<button type="button" class="info-cmp-remove" data-cmp-remove="\'+escHtml(it.code)+\'" aria-label="비교 제거">×</button>\'):"";'
'head+=\'<th class="info-cmp-col"><span class="info-cmp-name">\'+escHtml(it.name||it.code)+\'</span>\'+rm+\'</th>\';'
'});'
'var body="";'
'INFO_SPECS.forEach(function(s){'
'var cells=its.map(function(it){var j=CMP.data[it.code];return \'<td>\'+((j&&!j.error)?s.fn(j):"")+\'</td>\';}).join("");'
'body+=\'<tr><th class="info-cmp-label">\'+escHtml(s.k)+infoQbtn(s.d)+\'</th>\'+cells+\'</tr>\';'
'});'
'if(b)b.innerHTML=\'<div class="info-cmp-scroll"><table class="info-cmp-table"><thead><tr>\'+head+\'</tr></thead><tbody>\'+body+\'</tbody></table></div>\';'
'}'
'function infoRenderBody(){'
'var b=infoBodyEl();if(!b)return;'
'var missing=CMP.items.filter(function(it){return !CMP.data[it.code];});'
# 이미 표/그리드가 그려져 있으면 유지(비교 열 추가 시 깜빡임 방지), 빈 상태에서만 로딩 표시.
'if(missing.length&&!b.querySelector("table")&&!b.querySelector(".info-grid"))b.innerHTML=\'<div class="muted small info-loading">불러오는 중…</div>\';'
'Promise.all(missing.map(function(it){'
'return fetch("/api/stock_info?code="+encodeURIComponent(it.code),{credentials:"same-origin"})'
'.then(function(r){return r.json();})'
'.then(function(j){CMP.data[it.code]=(j||{error:"no data"});if(j&&j.name&&!it.name)it.name=j.name;})'
'.catch(function(){CMP.data[it.code]={error:"net"};});'
'})).then(function(){'
'var b2=infoBodyEl();if(!b2)return;'
'if(CMP.items.length<=1){'
'var j0=CMP.data[CMP.items[0].code];'
'if(!j0||j0.error){b2.innerHTML=\'<div class="muted small">불러오기 실패\'+((j0&&j0.error&&j0.error!=="net")?(": "+escHtml(j0.error)):"")+\'</div>\';return;}'
'renderSingleInfo(b2,j0);'
'}else{renderCompareInfo(b2);}'
'});'
'}'
'function addInfoCompare(code,name){'
'code=(code||"").trim();if(!code)return;'
'if(CMP.items.some(function(it){return it.code===code;})){'
'var rb=document.querySelector("#info-modal [data-info-search-results]");'
'if(rb){rb.classList.remove("hidden");rb.innerHTML=\'<div class="muted small">이미 비교 중인 종목입니다.</div>\';}'
'return;'
'}'
'CMP.items.push({code:code,name:name||""});'
'infoRenderBody();'
'}'
'function removeInfoCompare(code){'
'CMP.items=CMP.items.filter(function(it){return it.code!==code;});'
'infoRenderBody();'
'}'
# 내 목록(보유·관심·감시) 드롭다운 1회 로드.
'function loadInfoSymbols(){'
'if(CMP.symbolsLoaded)return;'
'var sel=document.querySelector("#info-modal [data-info-symbols]");if(!sel)return;'
'fetch("/api/symbols/all",{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
'var items=(d&&d.items)||[];if(!items.length)return;'
'var seen={};items.forEach(function(it){'
'if(!it.code||seen[it.code])return;seen[it.code]=1;'
'var o=document.createElement("option");o.value=it.code;'
'o.textContent=(it.name||it.code)+(it.source?(" · "+it.source):"");'
'o.setAttribute("data-name",it.name||"");sel.appendChild(o);'
'});'
'CMP.symbolsLoaded=true;'
'}).catch(function(){});'
'}'
# 종목 검색 — 단일이면 바로 추가, 다중이면 후보 버튼 표시.
'function doInfoSearch(){'
'var m=document.getElementById("info-modal");if(!m)return;'
'var inp=m.querySelector("[data-info-search]");var rb=m.querySelector("[data-info-search-results]");'
'var q=inp?inp.value.trim():"";if(!q){return;}'
'if(rb){rb.classList.remove("hidden");rb.innerHTML=\'<div class="muted small">검색 중…</div>\';}'
'fetch("/api/stock_search?q="+encodeURIComponent(q),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
'var res=(d&&d.results)||[];'
'if(!res.length){if(rb)rb.innerHTML=\'<div class="muted small">\'+escHtml((d&&d.error)||"검색 결과 없음")+\'</div>\';return;}'
'if(res.length===1){addInfoCompare(res[0].code,res[0].name);if(rb){rb.classList.add("hidden");rb.innerHTML="";}if(inp)inp.value="";return;}'
'if(rb)rb.innerHTML=res.map(function(it){return \'<button type="button" class="info-search-hit" data-cmp-add="\'+escHtml(it.code)+\'" data-cmp-name="\'+escHtml(it.name||"")+\'">\'+escHtml(it.name||it.code)+\' <span class="muted small">\'+escHtml(it.code)+\'</span></button>\';}).join("");'
'}).catch(function(){if(rb)rb.innerHTML=\'<div class="muted small">검색 오류</div>\';});'
'}'
'function openInfoModal(code,name){'
'var m=document.getElementById("info-modal");if(!m)return;'
'var t=m.querySelector("[data-info-title]");'
'if(t)t.textContent=(name||"기업정보")+(code?(" ("+code+")"):"");'
'var al=m.querySelector("[data-info-analyze]");'
'if(al){if(code){al.href="/stock/"+encodeURIComponent(code);al.style.display="";}else{al.style.display="none";}}'
# 상태 초기화 — 기준 종목 1개로 시작.
'CMP.items=[{code:code,name:name||""}];CMP.data={};'
'var inp=m.querySelector("[data-info-search]");if(inp)inp.value="";'
'var rb=m.querySelector("[data-info-search-results]");if(rb){rb.classList.add("hidden");rb.innerHTML="";}'
'var sel=m.querySelector("[data-info-symbols]");if(sel)sel.value="";'
'var b=m.querySelector("[data-info-body]");'
'if(b)b.innerHTML=\'<div class="muted small info-loading">불러오는 중…</div>\';'
'm.classList.remove("hidden");m.setAttribute("aria-hidden","false");'
'lockScroll();'
'loadInfoSymbols();'
'infoRenderBody();'
'}'
'function openTradeModal(code,name,owner){'
'var m=document.getElementById("trade-modal");if(!m)return;'
'var ownerMode=!code && !!owner;'
'var t=m.querySelector("[data-trade-title]");'
'if(t){t.textContent=ownerMode?(name+" 전체 거래내역"):(name+(code?(" ("+code+")"):""));}'
'var b=m.querySelector("[data-trade-body]");if(b)b.innerHTML=\'<div class="muted small">불러오는 중…</div>\';'
'var s=m.querySelector("[data-trade-summary]");if(s)s.textContent="";'
'm.classList.remove("hidden");m.setAttribute("aria-hidden","false");'
'lockScroll();'
'var qs=ownerMode?("owner="+encodeURIComponent(owner)):("code="+encodeURIComponent(code));'
'fetch("/api/trades?"+qs,{credentials:"same-origin"})'
'.then(function(r){return r.json();})'
'.then(function(j){'
'if(!j||j.error){if(b)b.innerHTML=\'<div class="muted small">불러오기 실패</div>\';return;}'
'if(!j.rows||!j.rows.length){if(b)b.innerHTML=\'<div class="muted small">기록이 없습니다.</div>\';return;}'
'var showStock=(j.mode==="owner");'
'function fmtAcct(a){'
'if(!showStock)return escHtml(a||"");'
'var s=String(a||"");'
'if(s.indexOf("가희_")===0)s=s.slice(3);'
'return escHtml(s);'
'}'
'function metaOf(r){'
'var d=escHtml(fmtTradeDate(r.date));'
'var acc=fmtAcct(r.account);'
'return \'<span class="ti-date">\'+d+\'</span><span class="ti-sep">·</span><span class="ti-acct">\'+acc+\'</span>\';'
'}'
'function stockLineOf(r){'
'if(!showStock)return "";'
'var nm=r.name||"";'
'return \'<div class="ti-stock-line trade-stock-col" title="\'+escHtml(nm)+\'">\'+escHtml(nm)+\'</div>\';'
'}'
'function renderRow(r){'
'var seedCls=r.seed?" seed":"";'
'var meta=metaOf(r);'
'var stockLine=stockLineOf(r);'
'var out="";'
'if(r.buy_qty){'
'out+=\'<li class="trade-item trade-item-buy\'+seedCls+\'">\'+stockLine+'
'\'<div class="ti-row1"><div class="ti-meta">\'+'
'\'<span class="ti-side side-buy">매수</span>\'+'
'\'<span class="ti-sep">·</span>\'+meta+\'</div>\'+'
'\'<span class="ti-pl muted">-</span></div>\'+'
'\'<div class="ti-row2"><div class="ti-data">\'+'
'\'<span class="ti-price"><span class="ti-label">평균가</span> \'+fmtInt(r.buy_avg)+\'원</span><span class="ti-sep">·</span>\'+'
'\'<span class="ti-qty"><span class="ti-label">수량</span> \'+fmtInt(r.buy_qty)+\'주</span><span class="ti-sep">·</span>\'+'
'\'<span class="ti-amt"><span class="ti-label">금액</span> \'+fmtInt(r.buy_amt)+\'원</span></div></div>\'+'
'\'</li>\';'
'}'
'if(r.sell_qty){'
'var pl=r.pl_amt||0;'
'var plCls=pl>0?"pl-pos":(pl<0?"pl-neg":"muted");'
'var sgn=pl>0?"+":"";'
'out+=\'<li class="trade-item trade-item-sell\'+seedCls+\'">\'+stockLine+'
'\'<div class="ti-row1"><div class="ti-meta">\'+'
'\'<span class="ti-side side-sell">매도</span>\'+'
'\'<span class="ti-sep">·</span>\'+meta+\'</div>\'+'
'\'<span class="ti-pl \'+plCls+\'">\'+sgn+fmtInt(pl)+\'원</span></div>\'+'
'\'<div class="ti-row2"><div class="ti-data">\'+'
'\'<span class="ti-price"><span class="ti-label">평균가</span> \'+fmtInt(r.sell_avg)+\'원</span><span class="ti-sep">·</span>\'+'
'\'<span class="ti-qty"><span class="ti-label">수량</span> \'+fmtInt(r.sell_qty)+\'주</span><span class="ti-sep">·</span>\'+'
'\'<span class="ti-amt"><span class="ti-label">금액</span> \'+fmtInt(r.sell_amt)+\'원</span></div></div>\'+'
'\'</li>\';'
'}'
'return out;'
'}'
'function renderTable(rs){'
'var list=\'<ul class="trade-list">\'+rs.map(renderRow).join("")+\'</ul>\';'
'var pager=\'<div class="trade-pager"><button type="button" class="pg-btn" data-pg="prev" aria-label="이전 페이지">◀</button><span class="pg-indicator">1 / 1</span><button type="button" class="pg-btn" data-pg="next" aria-label="다음 페이지">▶</button></div>\';'
'return \'<div class="trade-pager-wrap">\'+list+pager+\'</div>\';'
'}'
'var groups=Array.isArray(j.groups)?j.groups:[];'
# 활성 owner 탭(관리자=본인, 가희) 기준으로 처음 선택된 탭만 결정 — 탭 순서는 서버 응답(본인 먼저) 유지.
'var aoKey=window.__behive_active_owner?window.__behive_active_owner():null;'
'var prefer=aoKey==="gahee"?"가희":(aoKey==="self"?"본인":null);'
'var preferIdx=0;'
'if(prefer){'
'for(var gi=0;gi<groups.length;gi++){if((groups[gi].owner||"")===prefer){preferIdx=gi;break;}}'
'}'
'if(b){'
'if(groups.length>=2){'
'var tabsHtml=\'<div class="trade-tabs" role="tablist">\'+groups.map(function(g,idx){'
'var sel=idx===preferIdx?"true":"false";'
'return \'<button type="button" class="trade-tab" role="tab" data-trade-owner="\'+escHtml(g.owner||"")+\'" aria-selected="\'+sel+\'">\'+escHtml(g.label||g.owner||"")+\'</button>\';'
'}).join("")+\'</div>\';'
'var panelsHtml=groups.map(function(g,idx){'
'var hidden=idx===preferIdx?"":" hidden";'
'var sub=[];'
'if(g.total_buy_amt)sub.push("매수 "+fmtInt(g.total_buy_amt)+"");'
'if(g.total_sell_amt)sub.push("매도 "+fmtInt(g.total_sell_amt)+"");'
'if(g.total_pl_amt){var ss=g.total_pl_amt>0?"+":"";sub.push("실현 "+ss+fmtInt(g.total_pl_amt)+"");}'
'var subHtml=sub.length?\'<div class="trade-group-sub muted small">\'+escHtml(sub.join(" · "))+\'</div>\':"";'
'return \'<div class="trade-tab-panel" data-trade-panel="\'+escHtml(g.owner||"")+\'"\'+hidden+\'>\'+subHtml+renderTable(g.rows||[])+\'</div>\';'
'}).join("");'
'b.innerHTML=tabsHtml+panelsHtml;'
'}else{'
'b.innerHTML=renderTable(groups.length===1?(groups[0].rows||[]):j.rows);'
'}'
'setupTradePagers(b);'
'}'
'if(s){'
'var parts=[];'
'if(j.total_buy_amt)parts.push("총 매수 "+fmtInt(j.total_buy_amt)+"");'
'if(j.total_sell_amt)parts.push("총 매도 "+fmtInt(j.total_sell_amt)+"");'
'if(j.total_pl_amt){var sgn2=j.total_pl_amt>0?"+":"";parts.push("실현손익 "+sgn2+fmtInt(j.total_pl_amt)+"");}'
's.textContent=parts.join(" · ");'
'}'
'})'
'.catch(function(){if(b)b.innerHTML=\'<div class="muted small">네트워크 오류</div>\';});'
'}'
'function populateEditForm(trigger){'
'var em=document.getElementById("interest-edit-modal");if(!em)return;'
'var name=trigger.getAttribute("data-edit-stock")||"";'
'var code=trigger.getAttribute("data-edit-code")||"";'
'var title=em.querySelector("[data-edit-title]");'
'if(title)title.textContent=name+(code?(" ("+code+")"):"");'
'var f=em.querySelector("#interest-edit-form");if(!f)return;'
'f.querySelector("input[name=stock]").value=name;'
'f.querySelector("input[name=buy_price]").value=trigger.getAttribute("data-edit-buy")||"";'
'f.querySelector("input[name=target_price]").value=trigger.getAttribute("data-edit-target")||"";'
'f.querySelector("input[name=stop_price]").value=trigger.getAttribute("data-edit-stop")||"";'
'f.querySelector("input[name=memo]").value=trigger.getAttribute("data-edit-memo")||"";'
'var df=em.querySelector("#interest-edit-delete-form");'
'if(df)df.querySelector("input[name=stock]").value=name;'
'}'
'function populateTagForm(trigger){'
'var tm=document.getElementById("tag-modal");if(!tm)return;'
'var name=trigger.getAttribute("data-tag-stock")||"";'
'var code=trigger.getAttribute("data-tag-code")||"";'
'var curText=trigger.getAttribute("data-tag-text")||"";'
'var curLeader=trigger.getAttribute("data-tag-leader")==="1";'
'var title=tm.querySelector("[data-tag-title]");'
'if(title)title.textContent=name+(code?(" ("+code+")"):"");'
'var f=tm.querySelector("#tag-form");if(!f)return;'
'f.querySelector("input[name=stock]").value=name;'
'f.querySelector("input[name=code]").value=code;'
'f.querySelector("input[name=tag]").value=curText;'
'f.querySelector("input[name=leader]").value=curLeader?"1":"0";'
'tm.querySelectorAll(".btn-tag-quick[data-tag-toggle=leader]").forEach(function(b){'
'if(curLeader)b.classList.add("active");else b.classList.remove("active");'
'});'
'}'
'document.addEventListener("click",function(e){'
'var t=e.target;'
'var closeAttr=t.closest&&t.closest("[data-modal-close]");'
'if(closeAttr){e.preventDefault();closeModal(closeAttr.closest(".modal"));return;}'
'var tradeBtn=t.closest&&t.closest(".btn-trades");'
'if(tradeBtn){'
'e.preventDefault();'
'e.stopPropagation();'
'var tc=tradeBtn.getAttribute("data-trade-code")||"";'
'var tn=tradeBtn.getAttribute("data-trade-stock")||"";'
'var to=tradeBtn.getAttribute("data-trade-owner")||"";'
'openTradeModal(tc,tn,to);'
'return;'
'}'
'var infoBtn=t.closest&&t.closest(".btn-info");'
'if(infoBtn){'
'e.preventDefault();'
'e.stopPropagation();'
'openInfoModal(infoBtn.getAttribute("data-info-code")||"",infoBtn.getAttribute("data-info-stock")||"");'
'return;'
'}'
'var infoQ=t.closest&&t.closest(".info-q");'
'if(infoQ){'
'e.preventDefault();'
'e.stopPropagation();'
'openInfoDesc(infoQ.getAttribute("data-info-desc")||"");'
'return;'
'}'
'var infoSearchBtn=t.closest&&t.closest("[data-info-search-btn]");'
'if(infoSearchBtn){e.preventDefault();e.stopPropagation();doInfoSearch();return;}'
'var infoHit=t.closest&&t.closest(".info-search-hit");'
'if(infoHit){'
'e.preventDefault();e.stopPropagation();'
'var rb=document.querySelector("#info-modal [data-info-search-results]");'
'addInfoCompare(infoHit.getAttribute("data-cmp-add")||"",infoHit.getAttribute("data-cmp-name")||"");'
'if(rb){rb.classList.add("hidden");rb.innerHTML="";}'
'var inp=document.querySelector("#info-modal [data-info-search]");if(inp)inp.value="";'
'return;'
'}'
'var cmpRm=t.closest&&t.closest(".info-cmp-remove");'
'if(cmpRm){e.preventDefault();e.stopPropagation();removeInfoCompare(cmpRm.getAttribute("data-cmp-remove")||"");return;}'
'var tradeTab=t.closest&&t.closest(".trade-tab");'
'if(tradeTab){'
'e.preventDefault();'
'var tm=tradeTab.closest("#trade-modal");if(!tm)return;'
'var tgt=tradeTab.getAttribute("data-trade-owner")||"";'
'tm.querySelectorAll(".trade-tab").forEach(function(btn){'
'btn.setAttribute("aria-selected",btn.getAttribute("data-trade-owner")===tgt?"true":"false");'
'});'
'tm.querySelectorAll(".trade-tab-panel").forEach(function(p){'
'if(p.getAttribute("data-trade-panel")===tgt)p.removeAttribute("hidden");'
'else p.setAttribute("hidden","");'
'});'
'return;'
'}'
'var tagToggleBtn=t.closest&&t.closest("[data-tag-toggle=leader]");'
'if(tagToggleBtn){'
'e.preventDefault();'
'var ttm=document.getElementById("tag-modal");if(!ttm)return;'
'var ttf=ttm.querySelector("#tag-form");if(!ttf)return;'
'var leaderInp=ttf.querySelector("input[name=leader]");'
'var newVal=leaderInp.value==="1"?"0":"1";'
'leaderInp.value=newVal;'
'if(newVal==="1")tagToggleBtn.classList.add("active");'
'else tagToggleBtn.classList.remove("active");'
# 텍스트(name=tag) 인풋은 절대 건드리지 않음 — 사용자가 입력한 값 그대로 유지.
# 토글은 색깔(leader)만 뒤집고 즉시 서버 반영. 모달은 닫지 않음(keepOpen=true).
'submitForm(ttf,null,null,true);'
'return;'
'}'
'var tagDelBtn=t.closest&&t.closest("[data-tag-delete]");'
'if(tagDelBtn){'
'e.preventDefault();'
'var tdm=document.getElementById("tag-modal");if(!tdm)return;'
'var tdf=tdm.querySelector("#tag-form");if(!tdf)return;'
'tdf.querySelector("input[name=tag]").value="";'
'tdf.querySelector("input[name=leader]").value="0";'
'submitForm(tdf);'
'return;'
'}'
'var tagEditBtn=t.closest&&t.closest("[data-tag-edit]");'
'if(tagEditBtn){'
'e.preventDefault();'
'e.stopPropagation();'
'populateTagForm(tagEditBtn);'
'openModal("tag-modal");'
'return;'
'}'
'var openBtn=t.closest&&t.closest("[data-modal-open]");'
'if(openBtn){'
'e.preventDefault();'
'var oid=openBtn.getAttribute("data-modal-open");'
'if(oid==="interest-edit-modal"&&openBtn.hasAttribute("data-edit-stock")){'
'populateEditForm(openBtn);'
'}'
'if(oid==="interest-modal"&&openBtn.hasAttribute("data-prefill-stock")){'
'var addForm=document.getElementById("interest-add-form");'
'if(addForm){'
'var psName=openBtn.getAttribute("data-prefill-stock")||"";'
'var psCode=openBtn.getAttribute("data-prefill-code")||"";'
'var pi=addForm.querySelector("input[name=stock]");if(pi)pi.value=psName;'
'var pc=addForm.querySelector("input[name=code]");if(pc)pc.value=psCode;'
'}'
'}'
'openModal(oid);'
'return;'
'}'
'var candBtn=t.closest&&t.closest(".candidate-btn");'
'if(candBtn){'
'e.preventDefault();'
'var name=candBtn.getAttribute("data-cand-name")||"";'
'var code=candBtn.getAttribute("data-cand-code")||"";'
'var form=document.getElementById("interest-add-form");'
'if(form){'
'var ni=form.querySelector("input[name=stock]");if(ni)ni.value=name;'
'var ci=form.querySelector("input[name=code]");if(ci)ci.value=code;'
'closeModal(document.getElementById("candidate-modal"));'
'submitForm(form,name,code);'
'}'
'return;'
'}'
'});'
# 기업비교 — 내 목록 드롭다운 선택 시 추가, 검색창 Enter 시 검색.
'document.addEventListener("change",function(e){'
'var sel=e.target&&e.target.closest&&e.target.closest("[data-info-symbols]");'
'if(!sel||!sel.value)return;'
'var opt=sel.options[sel.selectedIndex];'
'addInfoCompare(sel.value,opt?(opt.getAttribute("data-name")||""):"");'
'sel.value="";'
'});'
'document.addEventListener("keydown",function(e){'
'if(e.key!=="Enter")return;'
'var inp=e.target&&e.target.closest&&e.target.closest("[data-info-search]");'
'if(!inp)return;'
'e.preventDefault();doInfoSearch();'
'});'
'document.addEventListener("submit",function(e){'
'var form=e.target;'
'if(!form)return;'
'if(form.id==="interest-add-form"||form.id==="interest-edit-form"||form.id==="tag-form"){'
'e.preventDefault();'
'submitForm(form);'
'return;'
'}'
'if(form.classList&&form.classList.contains("interest-delete-form")){'
'e.preventDefault();'
'if(!confirm("관심종목에서 삭제하시겠습니까?"))return;'
'submitForm(form);'
'return;'
'}'
'});'
'document.addEventListener("keydown",function(e){'
'if(e.key==="Escape"){'
'document.querySelectorAll(".modal:not(.hidden)").forEach(closeModal);'
'}'
'});'
'})();</script>'
)
# 거래내역 종목 셀 press-and-hold tooltip — pointerdown=show, pointerup/cancel/scroll=hide.
# 셀 상단에 띄워 손가락에 안 가리게. 화면 위 공간 부족하면 셀 하단으로 fallback.
stock_name_tip_script = (
'<script>(function(){'
'var tip=document.getElementById("stock-name-tip");if(!tip)return;'
'function place(cell){'
'var r=cell.getBoundingClientRect();'
'tip.classList.remove("below");'
'tip.classList.add("show");'
'var w=tip.offsetWidth,h=tip.offsetHeight;'
'var cx=r.left+r.width/2;'
'var left=cx-w/2;'
'var maxLeft=window.innerWidth-w-8;'
'if(left<8)left=8;else if(left>maxLeft)left=maxLeft;'
'var top=r.top-h-10;'
'if(top<8){top=r.bottom+10;tip.classList.add("below");}'
'tip.style.left=left+"px";'
'tip.style.top=top+"px";'
'}'
'function show(cell){'
'tip.textContent=cell.getAttribute("title")||cell.textContent||"";'
'place(cell);'
'tip.setAttribute("aria-hidden","false");'
'}'
'function hide(){'
'tip.classList.remove("show","below");'
'tip.setAttribute("aria-hidden","true");'
'}'
'document.addEventListener("pointerdown",function(e){'
'var cell=e.target.closest&&e.target.closest(".trade-stock-col");'
'if(!cell)return;'
'e.preventDefault();'
'show(cell);'
'},{passive:false});'
'document.addEventListener("pointerup",hide);'
'document.addEventListener("pointercancel",hide);'
'document.addEventListener("pointerleave",hide);'
'window.addEventListener("scroll",hide,true);'
'})();</script>'
)
# details.row[data-row-key] 그룹 mutex — 새로 열리면 다른 열려있던 것 자동 닫음.
# capture phase 사용: toggle 이벤트는 bubble 안 함. panels apply가 다시 그릴 때 setAttribute('open')
# 호출이 다시 trigger되어도 한 개만 남는다.
details_mutex_script = (
'<script>(function(){'
'document.addEventListener("toggle",function(e){'
'var d=e.target;'
'if(!d||d.nodeName!=="DETAILS")return;'
'if(!d.classList.contains("row"))return;'
'if(!d.hasAttribute("data-row-key"))return;'
'if(!d.open)return;'
'document.querySelectorAll("details.row[data-row-key][open]").forEach(function(o){'
'if(o!==d)o.removeAttribute("open");'
'});'
'},true);'
'})();</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'''<script>(function(){
var modal = document.getElementById('order-modal');
var pinModal = document.getElementById('pin-modal');
var openOrdersModal = document.getElementById('open-orders-modal');
if(!modal) return;
var ACCOUNTS = [
{label: '일반', display: '본인 일반', owner: '본인'},
{label: 'ISA', display: '본인 ISA', owner: '본인'},
{label: '가희_일반', display: '가희 일반', owner: '가희'},
{label: '가희_ISA', display: '가희 ISA', owner: '가희'}
];
var state = { code:'', name:'', side:'BUY', pollTimer:null, countdownTimer:null, expiryAt:0, isOpen:false, lastBook:null, bookCentered:false, lastCheck:null, marketActive:true, marketPhase:null, symbolsCache:null, pendingMaxOnSell:false, pendingMaxOnBuy:false };
function $(sel, root){ return (root||modal).querySelector(sel); }
function $$(sel, root){ return (root||modal).querySelectorAll(sel); }
function $p(sel){ return pinModal ? pinModal.querySelector(sel) : null; }
function fmt(n){ if(!isFinite(n)) return ''; return (Math.round(n)||0).toLocaleString('ko-KR'); }
function highlightSelectedTick(){
var inp = $('[data-order-price-input]');
var cur = parseInt((inp||{}).value || '0', 10);
$$('.ob-row').forEach(function(row){
var p = parseInt(row.getAttribute('data-ob-price') || '0', 10);
row.classList.toggle('selected', p > 0 && p === cur);
});
}
var __toastEl = null, __toastTimer = null;
function showToast(text, kind, durationMs){
if(!text) return;
if(!__toastEl){
__toastEl = document.createElement('div');
__toastEl.className = 'order-toast';
document.body.appendChild(__toastEl);
}
__toastEl.textContent = text;
__toastEl.className = 'order-toast ' + (kind || 'error');
void __toastEl.offsetWidth;
__toastEl.classList.add('show');
if(__toastTimer) clearTimeout(__toastTimer);
__toastTimer = setTimeout(function(){ if(__toastEl) __toastEl.classList.remove('show'); }, durationMs || 3500);
}
function setMsg(text, kind){
// 모달 내 메시지 영역은 hide, 알림은 토스트로
var el = $('[data-order-msg]'); if(el) el.classList.add('hidden');
if(text) showToast(text, kind);
}
function setSide(side){
state.side = side;
$$('.order-side-toggle button[data-side]').forEach(function(b){ b.classList.toggle('active', b.getAttribute('data-side')===side); });
var sb = $('[data-order-submit]'); if(sb){ sb.setAttribute('data-side', side); sb.textContent = (side==='BUY'?'매수':'매도'); }
renderQtyButtons();
// 매수일 때만 금액 input 표시
var budgetRow = $('[data-order-budget-row]');
if(budgetRow) budgetRow.style.display = (side === 'BUY') ? '' : 'none';
// 토글 시 budget input 초기화 — 이전 모드 잔존 방지 (양방향 동기화 fresh 시작)
var budgetInp = $('[data-order-budget]');
if(budgetInp) budgetInp.value = '';
// 토글 시 자동 max 플래그 — updateCheck 응답에서 max로 채움
// SELL: 보유주수 100%, BUY: 매수가능액/단가
if(side === 'SELL') state.pendingMaxOnSell = true;
else {
state.pendingMaxOnBuy = true;
// BUY max 계산엔 price 필요 — 비어있으면 호가창 현재가로 즉시 채움
_resolveOrderPrice();
}
updateCheck();
}
function _resolveOrderPrice(){
// 단가 우선, 없으면 현재가 fallback. 단가 input 비어있으면 자동 채움 (disabled 아닌 경우)
var priceInp = $('[data-order-price-input]');
var price = parseInt((priceInp||{}).value || '0', 10);
if(price <= 0 && state.lastBook && state.lastBook.price){
price = state.lastBook.price;
if(priceInp && !priceInp.disabled){
priceInp.value = price;
highlightSelectedTick();
}
}
return price;
}
function recalcQtyFromBudget(){
if(state.side !== 'BUY') return;
var budgetInp = $('[data-order-budget]');
var qtyInp = $('[data-order-qty]');
if(!budgetInp || !qtyInp) return;
var budget = parseInt(budgetInp.value || '0', 10);
var price = _resolveOrderPrice();
if(budget > 0 && price > 0){
qtyInp.value = Math.floor(budget / price);
renderOrderInfo();
}
}
function recalcBudgetFromQty(){
if(state.side !== 'BUY') return;
var budgetInp = $('[data-order-budget]');
var qtyInp = $('[data-order-qty]');
if(!budgetInp || !qtyInp) return;
var qty = parseInt(qtyInp.value || '0', 10);
var price = _resolveOrderPrice();
if(qty > 0 && price > 0){
budgetInp.value = qty * price;
}
}
function renderQtyButtons(){
var container = $('[data-qty-buttons]'); if(!container) return;
var btns;
if(state.side === 'BUY'){
btns = [['clear','C'], ['-1','-1'], ['1','+1'], ['10','+10'], ['max','전부']];
} else {
btns = [['0%','0%'], ['25%','25%'], ['50%','50%'], ['75%','75%'], ['100%','100%']];
}
container.innerHTML = btns.map(function(b){
return '<button type="button" data-qty-step="'+b[0]+'">'+b[1]+'</button>';
}).join('');
}
function renderBook(book){
state.lastBook = book;
if(book.name){ var ne = $('[data-order-name]'); if(ne) ne.textContent = book.name; state.name = book.name; }
var ce = $('[data-order-cur-price]'); if(ce) ce.textContent = fmt(book.price)+'';
var che = $('[data-order-change]');
if(che){
var sign = book.change>0?'+':'';
che.textContent = sign + fmt(book.change) + ' (' + (book.change_pct||0).toFixed(2) + '%)';
che.classList.remove('up','down');
if(book.change>0) che.classList.add('up'); else if(book.change<0) che.classList.add('down');
}
var ob = $('[data-orderbook]'); if(!ob) return;
var html = '';
var asks = (book.asks||[]).slice().reverse();
for(var i=0;i<asks.length;i++){
var a = asks[i];
html += '<div class="ob-row ask" data-ob-price="'+a.price+'"><span class="price">'+fmt(a.price)+'</span><span class="qty">'+fmt(a.qty)+'</span></div>';
}
html += '<div class="ob-divider">'+fmt(book.price)+'원</div>';
var bids = book.bids||[];
for(var j=0;j<bids.length;j++){
var b = bids[j];
html += '<div class="ob-row bid" data-ob-price="'+b.price+'"><span class="price">'+fmt(b.price)+'</span><span class="qty">'+fmt(b.qty)+'</span></div>';
}
ob.innerHTML = html;
var otSel = $('[data-order-type]');
if(otSel && otSel.value === 'MARKET' && book.price){
var inp = $('[data-order-price-input]');
if(inp && parseInt(inp.value||'0',10) !== book.price) inp.value = book.price;
}
highlightSelectedTick();
updateMarketPhaseDisplay();
if(!state.bookCentered){
var div = ob.querySelector('.ob-divider');
if(div){
ob.scrollTop = Math.max(0, div.offsetTop + div.offsetHeight/2 - ob.clientHeight/2);
state.bookCentered = true;
}
}
}
function pollOnce(){
if(document.hidden || !state.isOpen || !state.code) return;
fetch('/api/quote_book?code=' + encodeURIComponent(state.code))
.then(function(r){ return r.json(); })
.then(renderBook).catch(function(){});
}
function startPolling(){ stopPolling(); pollOnce(); state.pollTimer = setInterval(pollOnce, 1000); }
function stopPolling(){ if(state.pollTimer){ clearInterval(state.pollTimer); state.pollTimer=null; } }
function updateCheck(){
var acc = $('[data-order-account]'); if(!acc || !acc.value || !state.code) return;
var account = acc.value;
var price = parseInt(($('[data-order-price-input]')||{}).value || '0', 10);
var orderType = (($('[data-order-type]')||{}).value || 'LIMIT');
fetch('/api/order/check?code='+encodeURIComponent(state.code)+'&account='+encodeURIComponent(account)+'&side='+state.side+'&order_type='+orderType+'&price='+price)
.then(function(r){ return r.json(); })
.then(function(d){
if(d.error){ state.lastCheck=null; var info=$('[data-order-info]'); if(info) info.textContent = d.error; return; }
state.lastCheck = d;
var maxEl = $('[data-order-max]');
if(maxEl) maxEl.textContent = '(최대: ' + fmt(d.max_qty||0) + '주)';
// 매도 전환 시 수량 자동 100% (max_qty) — 사용자가 직접 변경 후엔 안 덮어씀
if(state.side === 'SELL' && state.pendingMaxOnSell && (d.max_qty||0) > 0){
var qtyInp = $('[data-order-qty]');
if(qtyInp){ qtyInp.value = d.max_qty; state.pendingMaxOnSell = false; }
}
// 매수 전환 시 수량 자동 max (매수가능액/단가) — budget도 동기화
if(state.side === 'BUY' && state.pendingMaxOnBuy && (d.max_qty||0) > 0){
var qtyInpB = $('[data-order-qty]');
if(qtyInpB){ qtyInpB.value = d.max_qty; state.pendingMaxOnBuy = false; recalcBudgetFromQty(); }
}
renderOrderInfo();
}).catch(function(){});
}
function renderOrderInfo(){
var d = state.lastCheck; if(!d) return;
var qty = parseInt(($('[data-order-qty]')||{}).value || '0', 10);
var price = parseInt(($('[data-order-price-input]')||{}).value || '0', 10);
var infoEl = $('[data-order-info]'); if(!infoEl) return;
var amount = price * qty;
var orderType = (($('[data-order-type]')||{}).value || 'LIMIT');
if(state.side === 'BUY'){
var lines = [
'예수금 (D+2): <b>'+fmt(d.d2_entra||0)+'</b>원',
'매수가능액: <b>'+fmt(d.ord_alow_amt||0)+'</b>원'
];
if(orderType === 'MARKET' && d.upper_limit){
var needed = (d.upper_limit||0) * qty;
var overCls = (needed > (d.ord_alow_amt||0)) ? ' class="qty-over"' : '';
lines.push('필요증거금<span class="muted small"> (상한가 '+fmt(d.upper_limit)+'원 기준)</span>: <b'+overCls+'>'+fmt(needed)+'</b>원 ('+fmt(qty)+'주)');
} else {
lines.push('주문예상액: <b>'+fmt(amount)+'</b>원 ('+fmt(qty)+'주)');
}
infoEl.innerHTML = lines.join('<br>');
} else {
var avg = d.avg_price||0;
var pl = (price - avg) * qty;
var plCls = pl>=0?'pl-up':'pl-down';
var plSign = pl>=0?'+':'';
infoEl.innerHTML =
'보유: <b>'+fmt(d.hold_qty||0)+'</b>주 (매도가능 '+fmt(d.trde_able_qty||0)+'주)<br>'+
'평단가: <b>'+fmt(avg)+'</b>원<br>'+
'예상수익: <span class="'+plCls+'">'+plSign+fmt(pl)+'원</span> ('+fmt(qty)+'주 매도)<br>'+
'주문예상액: <b>'+fmt(amount)+'</b>원';
}
// 수량 입력 — 최대치 초과 시 글자색 빨강 (예수금/보유 부족 시각화)
var qtyInp = $('[data-order-qty]');
if(qtyInp){
var maxq = parseInt((d.max_qty||0), 10);
if(qty > maxq && qty > 0) qtyInp.classList.add('qty-over');
else qtyInp.classList.remove('qty-over');
}
}
function gotoQty(step){
var inp = $('[data-order-qty]'); if(!inp) return;
var max = parseInt((state.lastCheck && state.lastCheck.max_qty) || 0, 10);
if(step === 'clear'){
inp.value = '0';
} else if(typeof step === 'string' && step.slice(-1) === '%'){
var pct = parseInt(step.slice(0, -1), 10);
inp.value = Math.floor(max * pct / 100);
} else if(step === 'max'){
inp.value = max;
} else {
var cur = parseInt(inp.value || '0', 10);
var delta = parseInt(step, 10);
inp.value = Math.max(0, cur + (isNaN(delta)?0:delta));
}
recalcBudgetFromQty();
renderOrderInfo();
}
function refreshActiveCount(){
fetch('/api/order/active').then(function(r){ return r.json(); }).then(function(d){
state.lastActive = d;
var el = $('[data-active-count]');
if(el){
var n = d.unique_count || 0;
el.textContent = n > 0 ? ' (' + n + ')' : '';
}
}).catch(function(){});
}
function showProgressTab(){
// [📋 진행중] 탭 — 활성 카드 있으면 PIN 모달, 없고 미체결만 있으면 open-orders 모달, 둘 다 없으면 토스트
fetch('/api/order/active').then(function(r){ return r.json(); }).then(function(d){
state.lastActive = d;
var el = $('[data-active-count]');
if(el){
var n = d.unique_count || 0;
el.textContent = n > 0 ? ' (' + n + ')' : '';
}
if(d.active){
openPinModal({
card_message: d.card_message || '',
expiry_seconds: d.expiry_seconds || 0,
symbol_name: d.symbol_name || ''
});
return;
}
if((d.open_orders_count || 0) > 0){
openOpenOrdersModal();
return;
}
showToast('진행 중인 거래 없음', 'info');
}).catch(function(e){ showToast('진행중 거래 조회 실패: ' + e, 'error'); });
}
function openOpenOrdersModal(){
if(!openOrdersModal) return;
openOrdersModal.classList.remove('hidden');
openOrdersModal.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
loadOpenOrders();
}
function closeOpenOrdersModal(){
if(!openOrdersModal) return;
openOrdersModal.classList.add('hidden');
openOrdersModal.setAttribute('aria-hidden', 'true');
if(modal.classList.contains('hidden') && (!pinModal || pinModal.classList.contains('hidden'))) document.body.classList.remove('modal-open');
refreshActiveCount();
}
function loadOpenOrders(){
var list = openOrdersModal.querySelector('[data-open-orders-list]');
if(!list) return;
list.innerHTML = '<div class="loading" style="padding:24px 0;"><div class="loading-spin"></div>불러오는 중…</div>';
fetch('/api/orders/open').then(function(r){ return r.json(); }).then(function(d){
var rows = d.rows || [];
if(!rows.length){
list.innerHTML = '<div class="empty">미체결 주문 없음</div>';
return;
}
list.innerHTML = rows.map(function(r){
var sideCls = r.side === 'BUY' ? 'buy' : 'sell';
var sideLabel = r.side === 'BUY' ? '매수' : '매도';
var priceLabel = r.order_price ? (r.order_price.toLocaleString('ko-KR') + '') : (r.order_type || '시장가');
var safeOrd = String(r.ord_no || '').replace(/"/g,'&quot;');
var safeAcc = String(r.account || '').replace(/"/g,'&quot;');
return (
'<div class="open-order-row" data-ord-no="' + safeOrd + '" data-account="' + safeAcc + '">' +
'<div class="row-info">' +
'<div class="row-line1">' +
'<span class="badge-side ' + sideCls + '">' + sideLabel + '</span>' +
'<span>' + (r.name || r.code) + '</span>' +
'<span style="color:#8b8f9a; font-weight:400; font-size:11px;">(' + r.code + ')</span>' +
'</div>' +
'<div class="row-line2">' +
'[' + r.account + '] ' + r.order_qty + '주 @ ' + priceLabel +
' · 미체결 ' + r.unfilled_qty + '주 · ' + r.status +
' · ' + r.exchange + ' · ' + (r.order_time || '') +
'</div>' +
'</div>' +
'<div class="row-action">' +
'<button type="button" data-cancel-open-order>취소</button>' +
'</div>' +
'</div>'
);
}).join('');
}).catch(function(e){
list.innerHTML = '<div class="empty">조회 실패: ' + e + '</div>';
});
}
function cancelOpenOrder(ordNo, account, btn){
if(!confirm('주문 #' + ordNo + ' 취소하시겠습니까?')) return;
if(btn){ btn.disabled = true; btn.textContent = '취소중…'; }
var body = new URLSearchParams();
body.set('ord_no', ordNo);
body.set('account', account);
fetch('/api/orders/cancel', {
method: 'POST',
headers: {'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},
body: body.toString()
}).then(function(r){ return r.json(); })
.then(function(d){
if(d.ok){
showToast(d.message || '✅ 취소 접수됨', 'success', 4000);
loadOpenOrders(); // 모달 안 리스트 갱신
} else {
showToast(d.message || d.error || '취소 실패', 'error');
if(btn){ btn.disabled = false; btn.textContent = '취소'; }
}
}).catch(function(e){
showToast('취소 오류: ' + e, 'error');
if(btn){ btn.disabled = false; btn.textContent = '취소'; }
});
}
function fetchMarketPhase(){
fetch('/api/market_state').then(function(r){ return r.json(); }).then(function(d){
state.marketActive = !!d.active;
state.marketPhase = d;
updateMarketPhaseDisplay();
}).catch(function(){});
}
function updateMarketPhaseDisplay(){
var phase = state.marketPhase; if(!phase) return;
var book = state.lastBook;
var label = phase.label || '';
var cls = phase.phase || '';
var canTrade = !!phase.active;
// NXT 시간대 + 종목 nxt_enable=false → 거래 불가 알림
if(phase.phase === 'nxt' && book && book.nxt_enable === false){
label = '📵 NXT 거래불가';
cls = 'closed';
canTrade = false;
}
var el = $('[data-market-phase]');
if(el){
el.textContent = label + ' ' + (phase.time || '');
el.className = 'order-market-phase ' + cls;
}
var sb = $('[data-order-submit]');
if(sb){
sb.disabled = !canTrade;
sb.title = canTrade ? '' : ('매매 비활성: ' + label);
}
}
function populateSymbolSelect(currentCode, currentName){
var sel = $('[data-order-symbol-select]'); if(!sel) return;
function render(items){
// 현재 종목이 리스트에 없으면 최상단에 추가
var has = items.some(function(it){ return it.code === currentCode; });
var opts = [];
if(currentCode && !has){
opts.push({code: currentCode, name: currentName || currentCode, source: '현재'});
}
// 그룹별 분리 (보유/관심/감시 순)
var groups = {'보유':[], '관심':[], '감시':[], '현재':[]};
opts.concat(items).forEach(function(it){ (groups[it.source]||groups['관심']).push(it); });
var html = '<option value="">종목 선택…</option>';
['현재','보유','관심','감시'].forEach(function(g){
if(!groups[g] || !groups[g].length) return;
html += '<optgroup label="'+g+'">';
groups[g].forEach(function(it){
var ownerTag = it.owner ? ' ['+it.owner+']' : '';
var label = (it.name||it.code) + ownerTag + ' ('+it.code+')';
var selAttr = (it.code === currentCode) ? ' selected' : '';
html += '<option value="'+it.code+'" data-name="'+(it.name||'').replace(/"/g,'&quot;')+'"'+selAttr+'>'+label+'</option>';
});
html += '</optgroup>';
});
sel.innerHTML = html;
}
if(state.symbolsCache){ render(state.symbolsCache); return; }
fetch('/api/symbols/all').then(function(r){ return r.json(); }).then(function(d){
state.symbolsCache = d.items || [];
render(state.symbolsCache);
}).catch(function(){
render([]);
});
}
function onSymbolChange(){
var sel = $('[data-order-symbol-select]'); if(!sel) return;
var opt = sel.selectedOptions[0]; if(!opt) return;
var code = opt.value;
if(!code || code === state.code) return;
var name = opt.getAttribute('data-name') || opt.textContent.split(' [')[0].split(' (')[0];
state.code = code;
state.name = name;
state.lastBook = null;
state.lastCheck = null;
state.bookCentered = false;
// 모달 안 표시 갱신
var ne = $('[data-order-name]'); if(ne) ne.textContent = name;
var cd = $('[data-order-code-display]'); if(cd) cd.textContent = '('+code+')';
// 호가창 클리어 + 새 종목 fetch
var ob = $('[data-orderbook]');
if(ob) ob.innerHTML = '<div class="loading" style="padding:30px 0;"><div class="loading-spin"></div>호가 로딩중…</div>';
$('[data-order-price-input]').value = '';
$('[data-order-qty]').value = '';
var budgetInp = $('[data-order-budget]');
if(budgetInp) budgetInp.value = '';
// 종목 변경 시 새 max로 자동 채움 (SELL: 보유 100%, BUY: 매수가능 max)
if(state.side === 'SELL') state.pendingMaxOnSell = true;
else state.pendingMaxOnBuy = true;
pollOnce();
updateCheck();
}
function fillAccounts(allowed){
var sel = $('[data-order-account]'); if(!sel) return;
sel.innerHTML = '';
var list = ACCOUNTS;
if(allowed && allowed.length){ list = ACCOUNTS.filter(function(a){ return allowed.indexOf(a.label) >= 0; }); }
list.forEach(function(a){ var opt = document.createElement('option'); opt.value=a.label; opt.textContent=a.display; sel.appendChild(opt); });
}
function reset(){
setMsg('');
$('[data-order-type]').value = 'LIMIT';
var priceInput = $('[data-order-price-input]');
if(priceInput){ priceInput.disabled = false; priceInput.value = ''; }
$('[data-order-qty]').value = '';
var budgetInput = $('[data-order-budget]');
if(budgetInput) budgetInput.value = '';
$('[data-order-max]').textContent = '(최대: —)';
$('[data-order-info]').textContent = '';
}
function gotoTick(direction){
var book = state.lastBook;
if(!book || !book.asks || !book.bids) return;
var prices = [];
for(var i=book.bids.length-1; i>=0; i--) prices.push(book.bids[i].price);
for(var j=0; j<book.asks.length; j++) prices.push(book.asks[j].price);
if(!prices.length) return;
var inp = $('[data-order-price-input]');
var cur = parseInt((inp||{}).value || '0', 10);
if(!cur){
inp.value = book.bids[0] ? book.bids[0].price : prices[0];
updateCheck(); highlightSelectedTick(); return;
}
var idx = prices.indexOf(cur);
if(idx < 0){
var closestIdx = 0, closestDiff = Math.abs(prices[0] - cur);
for(var k=1; k<prices.length; k++){
var d = Math.abs(prices[k] - cur);
if(d < closestDiff){ closestIdx = k; closestDiff = d; }
}
idx = closestIdx;
}
var newIdx = Math.max(0, Math.min(prices.length-1, idx + direction));
inp.value = prices[newIdx];
updateCheck();
highlightSelectedTick();
recalcQtyFromBudget();
}
function openModal(opts){
opts = opts || {};
reset();
state.code = String(opts.code || '');
state.name = String(opts.name || '');
state.isOpen = true;
state.bookCentered = false;
var ne = $('[data-order-name]'); if(ne) ne.textContent = state.name || '';
var cd = $('[data-order-code-display]'); if(cd) cd.textContent = state.code ? '('+state.code+')' : '';
fillAccounts(opts.accounts);
if(opts.account){ var sel = $('[data-order-account]'); if(sel) sel.value = opts.account; }
populateSymbolSelect(state.code, state.name);
setSide(opts.side === 'SELL' ? 'SELL' : 'BUY');
if(opts.price){ $('[data-order-price-input]').value = opts.price; }
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
startPolling();
updateCheck();
fetchMarketPhase();
refreshActiveCount();
}
function closeModal(){
state.isOpen = false; stopPolling();
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
// pin-modal이 안 떠 있을 때만 body unlock
if(!pinModal || pinModal.classList.contains('hidden')) document.body.classList.remove('modal-open');
}
// ── PIN 모달 ──
function resetPinModal(){
if(!pinModal) return;
var inStep = $p('[data-pin-input-step]'); if(inStep) inStep.classList.remove('hidden');
var rStep = $p('[data-pin-result-step]'); if(rStep) rStep.classList.add('hidden');
var pinInp = $p('[data-pin-input]'); if(pinInp){ pinInp.value=''; pinInp.disabled=false; }
var verifyBtn = $p('[data-pin-verify]'); if(verifyBtn) verifyBtn.disabled=false;
var cancelBtn = $p('[data-pin-cancel]'); if(cancelBtn) cancelBtn.disabled=false;
}
function openPinModal(opts){
if(!pinModal) return;
resetPinModal();
var sym = $p('[data-pin-symbol]'); if(sym) sym.textContent = opts.symbol_name || '';
var sum = $p('[data-pin-card-summary]'); if(sum) sum.textContent = (opts.card_message || '').replace(/\*/g, '');
startCountdown(opts.expiry_seconds || 120);
pinModal.classList.remove('hidden');
pinModal.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
setTimeout(function(){ var p=$p('[data-pin-input]'); if(p) p.focus(); }, 100);
}
function closePinModal(){
if(!pinModal) return;
// verify 안 하고 닫으면 활성 카드 자동 취소 (이미 consumed면 백엔드가 무동작)
fetch('/api/order/cancel', {method:'POST', headers:{'Accept':'application/json'}, body:''}).catch(function(){});
if(state.countdownTimer){ clearInterval(state.countdownTimer); state.countdownTimer=null; }
pinModal.classList.add('hidden');
pinModal.setAttribute('aria-hidden', 'true');
if(modal.classList.contains('hidden')) document.body.classList.remove('modal-open');
}
function startCountdown(seconds){
state.expiryAt = Date.now() + seconds*1000;
var cd = $p('[data-pin-countdown]');
function tick(){
var rem = Math.max(0, Math.floor((state.expiryAt - Date.now())/1000));
if(cd){
var mm = Math.floor(rem/60);
var ss = rem % 60;
cd.textContent = '' + (mm>0?(mm+''):'') + ss + '초 남음';
cd.classList.remove('warn','danger');
if(rem <= 10) cd.classList.add('danger');
else if(rem <= 30) cd.classList.add('warn');
}
if(rem <= 0){
clearInterval(state.countdownTimer); state.countdownTimer=null;
closePinModal();
showToast('⏰ PIN 만료됨 — 카드 자동 취소', 'info');
}
}
if(state.countdownTimer) clearInterval(state.countdownTimer);
tick(); state.countdownTimer = setInterval(tick, 500);
}
function doPropose(){
var account = $('[data-order-account]').value;
var orderType = $('[data-order-type]').value;
var qty = parseInt($('[data-order-qty]').value || '0', 10);
var price = parseInt($('[data-order-price-input]').value || '0', 10);
if(!account){ setMsg('계좌를 선택하세요', 'error'); return; }
if(qty <= 0){ setMsg('수량을 입력하세요', 'error'); return; }
if(orderType === 'LIMIT' && price <= 0){ setMsg('지정가는 단가가 필요합니다', 'error'); return; }
// 클라이언트 사전 차단 — 서버 왕복 없이 즉시 에러 (PIN 발급 중 토스트 깜빡임 방지)
var lc = state.lastCheck;
if(lc && typeof lc.max_qty === 'number' && lc.max_qty >= 0 && qty > lc.max_qty){
if(state.side === 'BUY'){
var basis = (orderType === 'MARKET' && lc.upper_limit) ? '상한가' : '단가';
var ref = (orderType === 'MARKET' && lc.upper_limit) ? lc.upper_limit : price;
var needed = ref * qty;
setMsg('예수금 부족 — 필요 ' + needed.toLocaleString() + '원 (' + basis + ' ' + (ref||0).toLocaleString() + '× ' + qty + '주) / 가용 ' + (lc.ord_alow_amt||0).toLocaleString() + '\n최대 ' + lc.max_qty + '주까지 매수 가능', 'error');
} else {
setMsg('보유 부족 — 매도 ' + qty + '주 / 매도가능 ' + lc.max_qty + '', 'error');
}
return;
}
var body = new URLSearchParams();
body.set('account', account);
body.set('side', state.side);
body.set('symbol', state.code);
body.set('symbol_name', state.name);
body.set('qty', String(qty));
body.set('order_type', orderType);
if(orderType === 'LIMIT') body.set('price', String(price));
setMsg('주문 검증 중…', 'info');
var btn = $('[data-order-submit]'); if(btn) btn.disabled = true;
// 이전 활성 카드 자동 정리 후 새 propose — "이전 카드가 아직 활성" 거부 방지
fetch('/api/order/cancel', {method:'POST', headers:{'Accept':'application/json'}, body:''})
.catch(function(){})
.then(function(){
return fetch('/api/order/propose', {
method: 'POST',
headers: {'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},
body: body.toString()
});
})
.then(function(r){ return r.json(); })
.then(function(d){
if(btn) btn.disabled = false;
if(!d.ok){ setMsg(d.message || d.error || 'PIN 발급 실패', 'error'); return; }
setMsg('');
// 별도 PIN 모달 띄움 (order-modal은 그대로 뒤에 남음)
openPinModal({
card_message: d.card_message || '',
expiry_seconds: d.expiry_seconds || 120,
symbol_name: state.name
});
}).catch(function(e){
if(btn) btn.disabled = false;
setMsg('네트워크 오류: ' + e, 'error');
});
}
function doVerify(){
var pinInp = $p('[data-pin-input]');
var pin = (pinInp && pinInp.value || '').trim();
if(!pin){ showToast('PIN을 입력하세요', 'error'); return; }
var body = new URLSearchParams(); body.set('pin', pin);
var btn = $p('[data-pin-verify]'); if(btn) btn.disabled = true;
var cancelBtn = $p('[data-pin-cancel]'); if(cancelBtn) cancelBtn.disabled = true;
fetch('/api/order/verify', {
method: 'POST',
headers: {'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},
body: body.toString()
}).then(function(r){ return r.json(); })
.then(function(d){
if(state.countdownTimer){ clearInterval(state.countdownTimer); state.countdownTimer=null; }
if(d.ok){
// 매매등록 성공 — PIN 모달 자동 닫기 + 토스트로 결과 알림
showToast(d.message || '✅ 주문 접수됨', 'success', 5000);
closePinModal();
return;
}
// 실패 — 모달 안 결과 step swap
var resultEl = $p('[data-pin-result]');
if(resultEl){
resultEl.textContent = d.message || d.error || '주문 실패';
resultEl.classList.remove('info','success');
resultEl.classList.add('error');
}
var inStep = $p('[data-pin-input-step]'); if(inStep) inStep.classList.add('hidden');
var rStep = $p('[data-pin-result-step]'); if(rStep) rStep.classList.remove('hidden');
}).catch(function(e){
if(btn) btn.disabled = false;
if(cancelBtn) cancelBtn.disabled = false;
showToast('네트워크 오류: ' + e, 'error');
});
}
// 주문 모달 click 핸들러
modal.addEventListener('click', function(e){
var t = e.target;
if(t.getAttribute && t.getAttribute('data-modal-close')==='1'){ closeModal(); return; }
if(t.classList && t.classList.contains('modal-close')){ closeModal(); return; }
var infoBtn = t.closest && t.closest('[data-show-active]');
if(infoBtn){ showProgressTab(); return; }
var sideBtn = t.closest && t.closest('.order-side-toggle button[data-side]');
if(sideBtn){ setSide(sideBtn.getAttribute('data-side')); return; }
if(t.matches && t.matches('[data-order-submit]')){ doPropose(); return; }
var tickBtn = t.closest && t.closest('[data-tick-dir]');
if(tickBtn){
var dir = parseInt(tickBtn.getAttribute('data-tick-dir') || '0', 10);
gotoTick(dir); return;
}
var qtyBtn = t.closest && t.closest('[data-qty-step]');
if(qtyBtn){ gotoQty(qtyBtn.getAttribute('data-qty-step')); return; }
var obRow = t.closest && t.closest('.ob-row');
if(obRow){
var p = parseInt(obRow.getAttribute('data-ob-price') || '0', 10);
if(p > 0){ var inp = $('[data-order-price-input]'); if(inp && !inp.disabled){ inp.value = p; updateCheck(); highlightSelectedTick(); recalcQtyFromBudget(); } }
}
});
// open-orders 모달 click 핸들러
if(openOrdersModal) openOrdersModal.addEventListener('click', function(e){
var t = e.target;
if(t.getAttribute && t.getAttribute('data-modal-close')==='1'){ closeOpenOrdersModal(); return; }
if(t.classList && t.classList.contains('modal-close')){ closeOpenOrdersModal(); return; }
var refBtn = t.closest && t.closest('[data-open-orders-refresh]');
if(refBtn){ loadOpenOrders(); return; }
var cancelBtn = t.closest && t.closest('[data-cancel-open-order]');
if(cancelBtn){
var row = cancelBtn.closest('.open-order-row');
if(!row) return;
cancelOpenOrder(row.getAttribute('data-ord-no'), row.getAttribute('data-account'), cancelBtn);
return;
}
});
// PIN 모달 click 핸들러
if(pinModal) pinModal.addEventListener('click', function(e){
var t = e.target;
if(t.getAttribute && t.getAttribute('data-modal-close')==='1'){ closePinModal(); return; }
if(t.classList && t.classList.contains('modal-close')){ closePinModal(); return; }
if(t.matches && t.matches('[data-pin-verify]')){ doVerify(); return; }
});
var accSel = $('[data-order-account]'); if(accSel) accSel.addEventListener('change', updateCheck);
var symSel = $('[data-order-symbol-select]'); if(symSel) symSel.addEventListener('change', onSymbolChange);
var otSel = $('[data-order-type]');
if(otSel) otSel.addEventListener('change', function(){
var isMarket = (otSel.value === 'MARKET');
var inp = $('[data-order-price-input]');
if(inp) inp.disabled = isMarket;
$$('[data-tick-dir]').forEach(function(b){ b.disabled = isMarket; });
// 주문유형 토글 시 max_qty 기준이 바뀜 (MARKET=상한가, LIMIT=단가) — 재조회
updateCheck();
});
var priceInpEl = $('[data-order-price-input]');
if(priceInpEl) priceInpEl.addEventListener('input', function(){ updateCheck(); highlightSelectedTick(); recalcQtyFromBudget(); });
var qtyInpEl = $('[data-order-qty]');
if(qtyInpEl) qtyInpEl.addEventListener('input', function(){ recalcBudgetFromQty(); renderOrderInfo(); });
var budgetInpEl = $('[data-order-budget]');
if(budgetInpEl) budgetInpEl.addEventListener('input', recalcQtyFromBudget);
document.addEventListener('visibilitychange', function(){
if(document.hidden) stopPolling();
else if(state.isOpen) startPolling();
});
// 보유종목·관심·감시종목 행의 .btn-order + 자산보기 [💰 거래] sub-tab 버튼 — panels swap 대응 위임
document.addEventListener('click', function(e){
// [💰 거래] sub-tab 버튼 — 본인 계좌 첫 보유종목 자동 선택 후 모달 열기
var subOrderBtn = e.target.closest && e.target.closest('[data-open-order-modal]');
if(subOrderBtn){
function openWithFirst(items){
var first = (items || []).find(function(it){ return it.source === '보유' && it.owner === '본인'; });
if(first){ openModal({code: first.code, name: first.name}); }
else { openModal({code: '', name: ''}); }
}
if(state.symbolsCache){ openWithFirst(state.symbolsCache); }
else {
fetch('/api/symbols/all').then(function(r){ return r.json(); }).then(function(d){
state.symbolsCache = d.items || [];
openWithFirst(state.symbolsCache);
}).catch(function(){ openModal({code: '', name: ''}); });
}
return;
}
// 보유/관심/감시 행의 거래 버튼 — 종목·side 자동 설정
var btn = e.target.closest && e.target.closest('.btn-order');
if(!btn) return;
var code = btn.getAttribute('data-order-code') || '';
var stock = btn.getAttribute('data-order-stock') || '';
var side = btn.getAttribute('data-order-side') || 'BUY';
if(code) openModal({code: code, name: stock, side: side});
});
window.openOrderModal = openModal;
window.closeOrderModal = closeModal;
window.openPinModal = openPinModal;
})();</script>'''
return f'''<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0f1115">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="자산현황">
<meta name="robots" content="noindex, nofollow">
<title>자산현황</title>
<style>{_CSS}{dynamic_css}</style>
{head_script}
</head>
<body>
<div class="page" id="page">
<div class="ptr" id="ptr" aria-hidden="true"><span class="arrow">↓</span><span class="spinner"></span></div>
<div class="topbar">
<header class="top">
<div class="titles">
<h1>자산현황</h1>
<span class="meta">갱신 {now}</span>
</div>
<div class="top-actions">
<button type="button" class="account-view-toggle-btn" data-account-view-state="consolidated" aria-label="계좌 보기 방식 (합산 / 각계좌별도) 토글" title="합산 / 각계좌별도 토글">↔</button>
<button id="auto-toggle" class="auto-toggle" type="button" aria-pressed="false" title="자동 갱신 사이클: off → 10s → 3s (자산정보·관리자·가희·관심종목 탭에서 동작, 페이지를 보고 있을 때만)"><span class="auto-dot"></span><span>자동</span></button>
<a class="refresh" href="#" onclick="event.preventDefault();window.__behive_manual_refresh&&window.__behive_manual_refresh();" aria-label="새로고침" title="새로고침">↻</a>
</div>
</header>
<nav class="tabs" role="tablist">
{nav_html}
</nav>
</div>
{content_html}
</div>
<div class="market-ticker" id="market-ticker"><span class="idx-empty">지수 불러오는 중…</span></div>
{idx_info_modal_html}
{interest_modal_html}
{interest_edit_modal_html}
{tag_modal_html}
{candidate_modal_html}
{trade_modal_html}
{info_modal_html}
{info_desc_modal_html}
{order_modal_html}
{pin_modal_html}
{open_orders_modal_html}
{stock_name_modal_html}
{ptr_script}
{panels_script}
{netchart_script}
{adr_hover_script}
{chart_svg_script}
{auto_reload_script}
{nodbltap_script}
{details_mutex_script}
{modal_script}
{order_modal_script}
{stock_name_tip_script}
</body>
</html>'''
# ── 패널 페이로드 인메모리 캐시 ──
# /api/panels GET마다 키움 호출 발생 → owner별 캐시로 사용자가 보는 탭만 갱신.
# key=None('all'): 전체 — kt00018·kt00001·ka10170 × 4계좌 + ka10095 = ~13콜
# key='본인': 본인 2계좌 — kt00018·kt00001·ka10170 × 2 = ~6콜 (워치리스트 quotes skip)
# key='가희': 가희 2계좌 — 동일 ~6콜
# 자동 갱신(10s) 주기에 맞춰 TTL=10s — 락이 동시 요청을 한 fetch로 모아 모바일 Safari timeout 흡수.
# 셸 HTML은 데이터 fetch 없이 매번 새로 만들어도 무료라 캐시 안 함. 워치리스트 변경(POST) 시 모든 owner 캐시 즉시 invalidate.
PANELS_CACHE_TTL = 10.0
_panels_cache: dict[str, dict] = {} # cache_key('all'|owner) → {'json': bytes, 'expires_at': float}
_panels_locks: dict[str, threading.Lock] = {}
_panels_locks_master = threading.Lock()
# 데이터 레이어 캐시 — owner별 raw owner_data + balances 보존.
# 자산정보 패널은 owner별 KPI 카드 스택이라 owner_data만으로 재렌더 가능. partial fetch가
# 자기 owner 슬라이스를 새로 채울 때 'all' 캐시의 summary 슬라이스와 해당 owner 탭만 in-place로
# 갈아끼워 자산정보-관리자 탭 간 KPI 불일치를 제거한다. TTL 별도 없음 — 'all'이 만료되면
# 어차피 full fetch로 동기화돼 누적 stale 위험 제한적.
_owner_data_cache: dict[str, dict] = {} # owner → {'owner_data': dict, 'balances': dict, 'fetched_at': float}
def _panels_lock_for(key: str) -> threading.Lock:
with _panels_locks_master:
lk = _panels_locks.get(key)
if lk is None:
lk = threading.Lock()
_panels_locks[key] = lk
return lk
def _save_owner_slices(owner_data: dict, balances: dict, journal_by_label: dict | None = None) -> None:
"""fetched owner_data를 owner 단위로 분리해 _owner_data_cache에 적재.
balances는 owner의 labels로 필터링 — 다른 owner 계좌 데이터가 섞이지 않도록.
journal_by_label도 동일하게 labels로 필터링해 보관 (자산보기 각계좌별도 토글 KPI용)."""
now_ts = time.time()
journal_by_label = journal_by_label or {}
for owner, d in owner_data.items():
owner_labels = d.get('labels') or []
owner_balances = {lb: balances[lb] for lb in owner_labels if lb in balances}
owner_journal = {lb: journal_by_label[lb] for lb in owner_labels if lb in journal_by_label}
_owner_data_cache[owner] = {
'owner_data': d,
'balances': owner_balances,
'journal_by_label': owner_journal,
'fetched_at': now_ts,
}
def _patch_all_cache_for_owner(owner: str, 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) -> None:
"""partial fetch 직후 'all' 캐시의 summary 패널과 해당 owner 패널만 in-place로 갈아끼운다.
다른 owner와 watchlist/interests 슬라이스는 cached 'all'에 있던 그대로 — TTL 만료 시 full fetch로 동기화.
핵심 목표: 자산정보 탭의 본인 KPI가 관리자 탭과 같은 시점이 되도록."""
all_key = f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}'
all_lock = _panels_lock_for(all_key)
with all_lock:
slot = _panels_cache.get(all_key)
if not slot or slot['expires_at'] <= time.time():
return # 캐시 없거나 만료 → 다음 자산정보 진입에서 어차피 full fetch
try:
payload = json.loads(slot['json'].decode('utf-8'))
except Exception:
traceback.print_exc()
return
merged = {o: e['owner_data'] for o, e in _owner_data_cache.items()}
if not merged:
return
ordered = sorted(merged.keys(), key=lambda o: (o != '본인', o))
# 각계좌별도 토글 KPI에 필요 — slice된 balances·journal_by_label을 owner 단위로 다시 union.
merged_balances: dict = {}
merged_journal: dict = {}
for e in _owner_data_cache.values():
merged_balances.update(e.get('balances') or {})
merged_journal.update(e.get('journal_by_label') or {})
new_summary = _render_summary_panel(ordered, merged, merged_balances, merged_journal, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days)
entry = _owner_data_cache.get(owner)
if not entry:
return
show_day_change = _show_day_change_now()
full_title = _owner_full_title(owner)
new_owner_panel = _render_owner_panel(owner, entry['owner_data'], entry['balances'], full_title, show_day_change, chart_days=chart_days, chart_unit=chart_unit)
owner_tab_id = f'tab-{_owner_tab_id(owner)}'
fetched_hms = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
for tab in payload.get('tabs', []):
tid = tab.get('id')
if tid == 'tab-summary':
tab['html'] = new_summary
tab['fetched_at'] = fetched_hms
elif tid == owner_tab_id:
tab['html'] = new_owner_panel
tab['fetched_at'] = fetched_hms
payload['now'] = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
payload['market_active'] = market_active
new_body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
# TTL 유지 — patch는 fetch가 아니라 일부 슬라이스만 갱신했으니 원래 만료 시각 그대로.
_panels_cache[all_key] = {'json': new_body, 'expires_at': slot['expires_at']}
def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, 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) -> bytes:
"""fresh=True 시 캐시 우회 — 명시적 새로고침(?fresh=1)에서 호출.
응답을 새로 만든 뒤에는 캐시에 덮어쓰므로 그 후 자동 갱신은 새 캐시를 본다.
chart_days × chart_unit × chart_mode × adr_days 별로 캐시 키 분리 — 옵션마다 다른 series라 같은 owner라도 별도 슬롯.
owner partial fetch 직후엔 'all' 캐시의 summary 슬라이스와 해당 owner 탭을 in-place로
갈아끼운다(_patch_all_cache_for_owner). 자산정보 = 본인+가희 KPI 카드 스택이라 owner_data만
있으면 재렌더 가능 — 다시 fetch할 필요 없음. 패치 실패 시 안전망으로 'all' 캐시 비워
다음 자산정보 진입에서 full fetch."""
key = f'{owner or "all"}:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}'
if not fresh:
slot = _panels_cache.get(key)
if slot and slot['expires_at'] > time.time():
return slot['json']
lock = _panels_lock_for(key)
with lock:
if not fresh:
slot = _panels_cache.get(key)
if slot and slot['expires_at'] > time.time():
return slot['json']
payload = _build_panels_payload(owner=owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days)
body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
_panels_cache[key] = {'json': body, 'expires_at': time.time() + PANELS_CACHE_TTL}
if owner is not None:
try:
_patch_all_cache_for_owner(owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days)
except Exception:
traceback.print_exc()
_panels_cache.pop(f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}', None)
return body
def _invalidate_panels_cache() -> None:
with _panels_locks_master:
_panels_cache.clear()
GZIP_MIN_BYTES = 1024 # 1KB 미만은 압축 오버헤드가 절감보다 큼 — 303 redirect·작은 ok 응답 등 skip
def _maybe_gzip(body: bytes, accept_encoding: str | None) -> tuple[bytes, str | None]:
"""클라이언트가 gzip 수용하면 압축본 반환. compresslevel=6은 디폴트(속도/크기 균형)."""
if not body or len(body) < GZIP_MIN_BYTES:
return body, None
ae = (accept_encoding or '').lower()
if 'gzip' not in ae:
return body, None
try:
return gzip.compress(body, compresslevel=6, mtime=0), 'gzip'
except Exception:
return body, None
class Handler(BaseHTTPRequestHandler):
server_version = 'BehiveWeb/1.0'
def log_message(self, fmt, *args): # type: ignore[override]
sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} {fmt % args}\n')
sys.stdout.flush()
def _send_json(self, code: int, payload: dict) -> None:
body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
self.send_response(code)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
if enc:
self.send_header('Content-Encoding', enc)
self.send_header('Vary', 'Accept-Encoding')
self.send_header('Cache-Control', 'no-store')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
def do_GET(self): # noqa: N802
from urllib.parse import urlparse, parse_qs
parsed = urlparse(self.path)
if parsed.path == '/api/trades':
qs = parse_qs(parsed.query)
code = (qs.get('code') or [''])[0].strip()
account = (qs.get('account') or [''])[0].strip()
owner_q = (qs.get('owner') or [''])[0].strip()
if not code and not owner_q:
self._send_json(400, {'error': 'code or owner required'})
return
try:
import trade_journal as tj
from stock_portfolio_report import OWNER_LABELS as _OWNER_LABELS
def _row_owner(r: dict) -> str:
return r.get('owner') or ('가희' if r.get('account', '').startswith('가희_') else '본인')
all_rows = tj._load_all()
if code:
rows = [r for r in all_rows if r.get('code') == code]
else:
rows = [r for r in all_rows if _row_owner(r) == owner_q]
if account:
rows = [r for r in rows if r.get('account') == account]
rows.sort(key=lambda r: (r.get('date', ''), r.get('account', '')), reverse=True)
name = rows[0]['name'] if rows and code else ''
mode = 'owner' if (not code and owner_q) else 'code'
total_buy = sum(r.get('buy_amt', 0) for r in rows)
total_sell = sum(r.get('sell_amt', 0) for r in rows)
total_pl = sum(r.get('pl_amt', 0) for r in rows)
total_fee = sum(r.get('cmsn_tax', 0) for r in rows)
def _owner_short(o: str) -> str:
full = _OWNER_LABELS.get(o, f'{o} 자산')
return full.replace('님 자산', '').replace(' 자산', '')
groups_map: dict[str, dict] = {}
for r in rows:
o = r.get('owner') or ('가희' if r.get('account', '').startswith('가희_') else '본인')
g = groups_map.setdefault(o, {
'owner': o,
'label': _owner_short(o),
'rows': [],
'total_buy_amt': 0,
'total_sell_amt': 0,
'total_pl_amt': 0,
'total_cmsn_tax': 0,
})
g['rows'].append(r)
g['total_buy_amt'] += r.get('buy_amt', 0)
g['total_sell_amt'] += r.get('sell_amt', 0)
g['total_pl_amt'] += r.get('pl_amt', 0)
g['total_cmsn_tax'] += r.get('cmsn_tax', 0)
ordered_groups = sorted(groups_map.values(), key=lambda g: (g['owner'] != '본인', g['owner']))
except Exception:
traceback.print_exc()
self._send_json(500, {'error': 'trades load failed'})
return
self._send_json(200, {
'code': code,
'name': name,
'owner': owner_q,
'mode': mode,
'account': account,
'rows': rows,
'groups': ordered_groups,
'total_buy_amt': total_buy,
'total_sell_amt': total_sell,
'total_pl_amt': total_pl,
'total_cmsn_tax': total_fee,
})
return
if parsed.path == '/api/symbols/all':
# 거래 모달 상단 종목 변경 드롭다운용 — 보유+관심+감시 통합 (dedup by code)
try:
import kiwoom_client as kc
seen: set = set()
items: list = []
# 보유 (4계좌)
try:
pos_by_acc = kc.get_positions_all()
for label, positions in (pos_by_acc or {}).items():
owner = '가희' if label.startswith('가희_') else '본인'
for p in positions:
code = p.get('code')
if not code or code in seen:
continue
seen.add(code)
items.append({'code': code, 'name': p.get('name', ''), 'source': '보유', 'owner': owner})
except Exception:
traceback.print_exc()
# 관심종목
try:
for c in _load_interests():
code = c.get('code')
if not code or code in seen:
continue
seen.add(code)
items.append({'code': code, 'name': c.get('stock', ''), 'source': '관심', 'owner': ''})
except Exception:
traceback.print_exc()
# 감시종목
try:
for c in _load_watchlist():
code = c.get('code')
if not code or code in seen:
continue
seen.add(code)
items.append({'code': code, 'name': c.get('stock', ''), 'source': '감시', 'owner': ''})
except Exception:
traceback.print_exc()
self._send_json(200, {'items': items, 'count': len(items)})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'symbols failed: {e}'})
return
if parsed.path == '/api/orders/open':
# 4계좌 통합 미체결 주문 — open-orders-modal이 표시
try:
import kiwoom_client as kc
rows = kc.get_open_orders_all()
self._send_json(200, {'rows': rows, 'count': len(rows)})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'open orders failed: {e}'})
return
if parsed.path == '/api/order/active':
# 현재 활성 카드 (PinStore.peek) + 미체결 주문 정보 — 거래 모달 [📋 진행중] 탭이 표시
try:
import time as _time
from orders.pin import PinStore
pending = PinStore().peek()
unique_codes: set = set()
result: dict = {'active': False}
if pending is not None and not pending.is_expired() and not pending.consumed:
payload = pending.payload or {}
elapsed = _time.time() - pending.issued_at
remaining = max(0, int(pending.expiry_seconds - elapsed))
side_label = '매수' if payload.get('side') == 'BUY' else '매도'
price_val = payload.get('price')
price_str = f"{price_val:,}" if price_val else '시장가'
card_message = (
f"💳 활성 카드 [{pending.card_id}]\n"
f"종목: {payload.get('symbol_name','')} ({payload.get('symbol','')})\n"
f"{side_label} {payload.get('qty',0):,}주 @ {price_str}\n"
f"계좌: {payload.get('account','')}"
)
result = {
'active': True,
'card_id': pending.card_id,
'symbol_name': payload.get('symbol_name', ''),
'symbol': payload.get('symbol', ''),
'card_message': card_message,
'expiry_seconds': remaining,
}
if payload.get('symbol'):
unique_codes.add(payload['symbol'])
# 4계좌 미체결 주문 ka10075 통합
open_orders_count = 0
try:
import kiwoom_client as kc
rows = kc.get_open_orders_all()
open_orders_count = len(rows)
for r in rows:
if r.get('code'):
unique_codes.add(r['code'])
except Exception:
traceback.print_exc()
result['open_orders_count'] = open_orders_count
result['unique_count'] = len(unique_codes)
self._send_json(200, result)
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'active card lookup failed: {e}'})
return
if parsed.path == '/api/market_state':
try:
self._send_json(200, _market_phase_state())
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'market_state failed: {e}'})
return
if parsed.path == '/api/quote_book':
# 거래 모달 호가창용. ka10004(호가 10단계+잔량) + ka10001(현재가·등락) 묶음.
# 1초 polling 가정 — visibility 가드는 클라이언트 측 책임.
qs = parse_qs(parsed.query)
code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
if not code:
self._send_json(400, {'error': 'code required'})
return
try:
import kiwoom_client as kc
book = kc.get_quote_book(code)
quote = kc.get_stock_quote(code)
book['price'] = quote.get('price', 0)
book['change'] = quote.get('change', 0)
book['change_pct'] = quote.get('change_pct', 0.0)
book['name'] = quote.get('name', '')
# NXT 거래 가능 여부 (ka10099 캐시 nxtEnable) — 캐시 없거나 모르면 보수적 True
try:
meta = kc.lookup_stock_meta(code)
book['nxt_enable'] = True if (not meta) else bool(meta.get('nxt_enable', True))
except Exception:
book['nxt_enable'] = True
book.pop('raw', None)
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'quote_book failed: {e}'})
return
self._send_json(200, book)
return
if parsed.path == '/api/stock_info':
# 기업정보 모달용. ka10001 기반 시총·PER·PBR·EPS·BPS·ROE·52주·외국인비율.
qs = parse_qs(parsed.query)
code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
if not code:
self._send_json(400, {'error': 'code required'})
return
try:
import kiwoom_client as kc
info = kc.get_stock_basic(code)
info.pop('raw', None)
# 분기 영업이익(네이버) — 확정 분기 중 최신. 실패해도 키움 데이터는 유지.
q = _fetch_quarter_op_profit(code)
info['op_profit_q'] = q['value'] if q else None
info['op_profit_q_period'] = q['period'] if q else None
# FnGuide 성장성·컨센서스 보강 (조회 실패·ETF면 None).
try:
import fnguide_client as fg
fund = fg.get_fundamentals(code)
info['fundamentals'] = {
'growth': fund.get('growth'),
'latest_period': fund.get('latest_period'),
'consensus': fund.get('consensus'),
} if fund else None
except Exception:
info['fundamentals'] = None
# WISEreport 컨센서스(컴팩트) — 리비전 방향·서프라이즈·올해 추정.
try:
import wisereport_client as wr
cons = wr.get_consensus(code)
if cons:
rev = cons.get('revision') or {}
sup = (cons.get('surprise') or {}).get('items') or {}
est_row = next((a for a in (cons.get('annual') or [])
if a.get('is_estimate')), None)
info['consensus'] = {
'target_change_pct': rev.get('target_change_pct'),
'est_change_pct': rev.get('est_change_pct'),
'rev_item': rev.get('item_name'),
'rev_weeks': rev.get('n_points'),
'this_year': ({
'period': est_row.get('period'),
'sales': est_row.get('sales'),
'op': est_row.get('op'),
'eps': est_row.get('eps'),
'roe': est_row.get('roe'),
} if est_row else None),
'surprise_sales': (sup.get('매출액') or {}).get('fy0_surprise_pct'),
'surprise_op': (sup.get('영업이익') or {}).get('fy0_surprise_pct'),
'surprise_year': (sup.get('매출액') or {}).get('fy0_year'),
}
else:
info['consensus'] = None
except Exception:
info['consensus'] = None
# 최근 증권사 분석리포트 (메타+요약, PDF 제외). 없으면 None.
try:
import wisereport_client as wr2
info['reports'] = wr2.get_reports(code, limit=6)
except Exception:
info['reports'] = None
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'stock_info failed: {e}'})
return
self._send_json(200, info)
return
if parsed.path == '/api/stock_search':
# 기업비교용 종목 검색. 단일 매칭이면 1건, 다중이면 후보 리스트 반환.
qs = parse_qs(parsed.query)
q = (qs.get('q') or [''])[0].strip()
if not q:
self._send_json(400, {'error': 'q required'})
return
try:
hit = _resolve_stock_fuzzy(q)
self._send_json(200, {'results': [{'code': hit['code'], 'name': hit['name']}]})
except MultipleMatchError as e:
self._send_json(200, {'results': [{'code': c['code'], 'name': c['name']} for c in e.candidates[:20]]})
except Exception as e:
self._send_json(200, {'results': [], 'error': str(e)})
return
if parsed.path == '/api/order/check':
# 거래 모달용: 계좌·종목·side(BUY/SELL)·order_type·단가(선택) 받아서
# 최대 거래 가능 주수, 잔액·보유주수·평단가, 매도 시 손익 미리보기 반환.
# MARKET BUY 는 키움 증거금 기준(상한가)로 max_qty 계산해 사전 차단.
qs = parse_qs(parsed.query)
code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
account = (qs.get('account') or [''])[0].strip()
side = (qs.get('side') or [''])[0].strip().upper()
order_type = (qs.get('order_type') or ['LIMIT'])[0].strip().upper() or 'LIMIT'
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'):
order_type = 'LIMIT'
try:
price = int((qs.get('price') or ['0'])[0].strip() or '0')
except ValueError:
price = 0
if not code or not account or side not in ('BUY', 'SELL'):
self._send_json(400, {'error': 'code/account required, side=BUY|SELL'})
return
try:
import kiwoom_client as kc
result = {'code': code, 'account': account, 'side': side,
'order_type': order_type, 'price': price}
if side == 'BUY':
bal = kc.get_balance(account)
ord_alow = int(bal.get('ord_alow_amt') or 0)
result['ord_alow_amt'] = ord_alow
result['d2_entra'] = int(bal.get('d2_entra') or 0)
if order_type == 'MARKET':
# 키움 시장가 매수 증거금 = 상한가 × qty. 같은 기준으로 max_qty 표시.
try:
q = kc.get_stock_quote(code, account_label=account, exchange='AL')
raw = q.get('raw') if isinstance(q, dict) and isinstance(q.get('raw'), dict) else (q or {})
upper = abs(int(raw.get('upl_pric') or 0))
except Exception:
upper = 0
result['upper_limit'] = upper
result['max_qty'] = (ord_alow // upper) if upper > 0 else 0
else:
# 키움 ord_alow_amt는 수수료 버퍼 이미 반영된 매수가능액. price>0 일 때만 max_qty.
result['max_qty'] = (ord_alow // price) if price > 0 else 0
else: # SELL
positions = kc.get_positions(account)
pos = next((p for p in positions if p['code'] == code), None)
if pos:
result['hold_qty'] = pos['qty']
result['trde_able_qty'] = pos['trde_able_qty']
result['avg_price'] = pos['avg_price']
result['cur_price'] = pos['cur_price']
result['max_qty'] = pos['trde_able_qty']
# 선택 단가 기준 손익 미리보기 — 단가 0이면 현재가 fallback
ref_price = price or pos['cur_price']
result['pl_preview'] = (ref_price - pos['avg_price']) * result['max_qty']
else:
result.update({'hold_qty': 0, 'trde_able_qty': 0, 'avg_price': 0,
'cur_price': 0, 'max_qty': 0, 'pl_preview': 0})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'order/check failed: {e}'})
return
self._send_json(200, result)
return
if parsed.path == '/api/chart_svg':
qs = parse_qs(parsed.query)
code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
unit = (qs.get('unit') or [''])[0].strip()
if not code:
self._send_json(400, {'error': 'code required'})
return
# 분봉 분기 — 캐시 미사용 (장중 매 호출마다 ka10080 한 콜). 응답은 SVG 단일.
if unit in ('1m', '5m', '15m'):
tic_scope = {'1m': 1, '5m': 5, '15m': 15}[unit]
# range 파라미터 — 30min/1h/3h/5h/1d. 분 단위로 변환. 1d=None(오늘 전체).
range_str = (qs.get('range') or ['1d'])[0].strip()
range_minutes_map = {'30min': 30, '1h': 60, '3h': 180, '5h': 300, '1d': None}
if range_str not in range_minutes_map:
range_str = '1d'
range_minutes = range_minutes_map[range_str]
try:
import stock_analysis as sa
import kiwoom_client as kc
mins = kc.get_minute_candles(code, tic_scope=tic_scope, today_only=True)
if not mins:
self.send_error(404, 'no minute candles')
return
# range 슬라이스 — 최근 N 봉만.
if range_minutes is not None:
num_bars = max(1, range_minutes // tic_scope)
mins = mins[-num_bars:]
# render_svg_chart 호환 — date 필드에 HH:MM 박아 X축 라벨로 사용.
formatted = []
for m in mins:
t = m.get('time', '')
hhmm = f'{t[:2]}:{t[2:4]}' if len(t) >= 4 else t
formatted.append({**m, 'date': hhmm, 'value': 0, 'turnover_rate': None})
closes = [c['close'] for c in formatted]
snap = {
'candles_asc': formatted,
'sma5': sa.sma(closes, 5),
'sma20': sa.sma(closes, 20),
'sma60': sa.sma(closes, 60),
}
svg = sa.render_svg_chart(snap, range_key='1Y')
body = svg.encode('utf-8')
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
self.send_response(200)
self.send_header('Content-Type', 'image/svg+xml; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
if enc:
self.send_header('Content-Encoding', enc)
self.send_header('Vary', 'Accept-Encoding')
self.send_header('Cache-Control', 'no-store')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
except Exception:
traceback.print_exc()
self.send_error(500, 'minute chart render failed')
return
try:
import daily_candles_cache as dcc
import stock_analysis as sa
import kiwoom_client as kc
candles_asc = dcc.get_candles(code, count=250)
today = datetime.now(KST).strftime('%Y%m%d')
try:
qmap = kc.get_watchlist_quotes([code], exchange='AL')
tq = qmap.get(code) if isinstance(qmap, dict) else None
except Exception:
tq = None
live_today_ok = bool(
tq and tq.get('price') and tq.get('open')
and tq.get('high') and tq.get('low')
)
if (live_today_ok
and (not candles_asc or candles_asc[-1]['date'] != today)):
candles_asc = candles_asc + [{
'date': today,
'open': tq['open'],
'high': tq['high'],
'low': tq['low'],
'close': tq['price'],
'volume': tq.get('volume', 0),
'value': 0,
'turnover_rate': None,
}]
if not candles_asc:
self.send_error(404, 'no candles')
return
closes = [c['close'] for c in candles_asc]
snap = {
'candles_asc': candles_asc,
'sma5': sa.sma(closes, 5),
'sma20': sa.sma(closes, 20),
'sma60': sa.sma(closes, 60),
}
# 분석 페이지와 동일 패턴 — 1Y/6M/1M 3 개 panel + 탭. 250 봉 캐시 슬라이스만 다름.
# 분봉 panel 은 빈 placeholder. 탭 클릭 시 클라이언트가 lazy fetch.
svg_1y = sa.render_svg_chart(snap, range_key='1Y')
svg_6m = sa.render_svg_chart(snap, range_key='6M')
svg_1m = sa.render_svg_chart(snap, range_key='1M')
# 분봉 panel 안에 기간 sub-tabs (30분/1h/3h/5h/하루). default = '1h'.
# subpanel 은 클라이언트가 sub-toggle 이벤트로 lazy fetch.
def _minute_panel(unit_id: str) -> str:
return (
f'<div class="chart-panel" data-range="{unit_id}" data-unit="{unit_id}">'
'<div class="chart-subtabs">'
'<button type="button" data-subrange="30min">30분</button>'
'<button type="button" data-subrange="1h" class="active">1시간</button>'
'<button type="button" data-subrange="3h">3시간</button>'
'<button type="button" data-subrange="5h">5시간</button>'
'<button type="button" data-subrange="1d">하루</button>'
'</div>'
'<div class="chart-subpanel"></div>'
'</div>'
)
html_block = (
'<div class="chart-tabs">'
'<button type="button" data-range="1Y">1년</button>'
'<button type="button" data-range="6M">6개월</button>'
'<button type="button" data-range="1M" class="active">1개월</button>'
'<button type="button" data-range="5m" data-unit="5m">5분봉</button>'
'<button type="button" data-range="1m" data-unit="1m">1분봉</button>'
'</div>'
f'<div class="chart-panel" data-range="1Y">{svg_1y}</div>'
f'<div class="chart-panel" data-range="6M">{svg_6m}</div>'
f'<div class="chart-panel active" data-range="1M">{svg_1m}</div>'
+ _minute_panel('5m')
+ _minute_panel('1m')
)
body = html_block.encode('utf-8')
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
if enc:
self.send_header('Content-Encoding', enc)
self.send_header('Vary', 'Accept-Encoding')
self.send_header('Cache-Control', 'no-store')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
except Exception:
traceback.print_exc()
self.send_error(500, 'chart render failed')
return
if parsed.path == '/api/idx-chart':
qs = parse_qs(parsed.query)
sym = (qs.get('symbol') or [''])[0]
if sym not in _IDX_CHART_SYMBOL.values():
self.send_error(400, 'bad symbol')
return
series = _fetch_idx_chart_series(sym)
if not series:
self.send_error(502, 'fetch failed')
return
body = json.dumps({'symbol': sym, 'series': series}, ensure_ascii=False).encode('utf-8')
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
if enc:
self.send_header('Content-Encoding', enc)
self.send_header('Vary', 'Accept-Encoding')
self.send_header('Cache-Control', 'max-age=60')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
return
if parsed.path == '/api/panels':
qs = parse_qs(parsed.query)
tab_key = (qs.get('owner') or [None])[0]
fresh = (qs.get('fresh') or ['0'])[0] == '1'
# 명시적 새로고침(fresh=1)일 땐 시장 지수 캐시도 무효화 — 시장 카드 새로고침 버튼이 fresh=1 사용.
if fresh:
with _indices_lock:
_indices_cache['expires_at'] = 0
with _market_indicators_lock:
_market_indicators_cache['expires_at'] = 0
with _extra_market_quotes_lock:
_extra_market_quotes_cache['expires_at'] = 0
owner = TAB_KEY_TO_OWNER.get(tab_key) if tab_key else None
try:
chart_days = int((qs.get('chart_days') or [str(NET_WORTH_CHART_DAYS)])[0])
except (TypeError, ValueError):
chart_days = NET_WORTH_CHART_DAYS
chart_unit = (qs.get('chart_unit') or [NET_WORTH_CHART_UNIT])[0]
if chart_unit not in {u for _, u in NET_WORTH_UNIT_PRESETS}:
chart_unit = NET_WORTH_CHART_UNIT
# 기간×단위 비허용 조합은 첫 허용 단위로 강제 (stale localStorage 방어)
_allowed = NET_WORTH_ALLOWED_UNITS_BY_DAYS.get(chart_days)
if _allowed and chart_unit not in _allowed:
chart_unit = _allowed[0]
chart_mode = (qs.get('chart_mode') or [NET_WORTH_CHART_MODE])[0]
if chart_mode not in {m for _, m in NET_WORTH_MODE_PRESETS}:
chart_mode = NET_WORTH_CHART_MODE
try:
adr_days = int((qs.get('adr_days') or [str(ADR_TREND_DAYS)])[0])
except (TypeError, ValueError):
adr_days = ADR_TREND_DAYS
if adr_days not in {d for _, d in ADR_TREND_PRESETS}:
adr_days = ADR_TREND_DAYS
try:
body = _get_cached_panels_json(owner=owner, fresh=fresh, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days)
except Exception:
traceback.print_exc()
self.send_error(500, 'panels failed')
return
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
if enc:
self.send_header('Content-Encoding', enc)
self.send_header('Vary', 'Accept-Encoding')
self.send_header('Cache-Control', 'no-store')
self.send_header('Referrer-Policy', 'no-referrer')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
return
# /stock/<code> — 종목 분석 상세 페이지
if parsed.path.startswith('/stock/'):
tail = parsed.path[len('/stock/'):].strip('/')
if not tail:
self.send_error(404)
return
code = ''.join(ch for ch in tail.split('/')[0] if ch.isalnum())
if not code:
self.send_error(400, 'bad stock code')
return
qs = parse_qs(parsed.query)
selected = (qs.get('r') or [None])[0]
err_msg = (qs.get('err') or [None])[0]
try:
import stock_analysis as sa
page_html = sa.render_stock_page(code, selected_filename=selected, error_msg=err_msg)
except Exception:
traceback.print_exc()
self.send_error(500, 'analysis page failed')
return
body = page_html.encode('utf-8')
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
if enc:
self.send_header('Content-Encoding', enc)
self.send_header('Vary', 'Accept-Encoding')
self.send_header('Cache-Control', 'no-store')
self.send_header('Referrer-Policy', 'no-referrer')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
return
if parsed.path not in ('/', '/index.html'):
self.send_error(404)
return
try:
# 셸 HTML은 데이터 fetch 없이 즉시 응답 — 키움 호출은 /api/panels에서.
html_body = render_html()
except Exception:
traceback.print_exc()
self.send_error(500, 'render failed')
return
body = html_body.encode('utf-8')
body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
if enc:
self.send_header('Content-Encoding', enc)
self.send_header('Vary', 'Accept-Encoding')
self.send_header('Cache-Control', 'no-store')
self.send_header('Referrer-Policy', 'no-referrer')
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass
def do_HEAD(self): # noqa: N802
# PWA navigation preload·link preview·NAS reverse proxy health check 등이 HEAD를 보낸다.
# 키움 호출 트리거 없이 200만 돌려준다.
if self.path not in ('/', '/index.html'):
self.send_error(404)
return
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Cache-Control', 'no-store')
self.send_header('Referrer-Policy', 'no-referrer')
self.end_headers()
def do_POST(self): # noqa: N802
wl_actions = {'/delete': 'delete', '/restore': 'restore', '/purge': 'purge'}
in_actions = {'/interests/add': 'add', '/interests/update': 'update', '/interests/delete': 'delete'}
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length).decode('utf-8') if length else ''
from urllib.parse import parse_qs
# keep_blank_values=True: edit modal의 "비우면 제거" UX가 동작하려면 빈 값도 payload에 살아있어야 한다.
params = parse_qs(body, keep_blank_values=True)
stock = (params.get('stock') or [''])[0].strip()
wants_json = 'application/json' in (self.headers.get('Accept') or '')
if self.path == '/api/order/propose':
# 거래 모달 1단계: 주문 정보 → handler.propose_and_send → 카드 발급, PIN은 텔레그램.
account = (params.get('account') or [''])[0].strip()
side = (params.get('side') or [''])[0].strip().upper()
symbol = ''.join(ch for ch in (params.get('symbol') or [''])[0].strip() if ch.isalnum())
symbol_name = (params.get('symbol_name') or [''])[0].strip()
order_type = (params.get('order_type') or ['LIMIT'])[0].strip().upper()
try:
qty = int((params.get('qty') or ['0'])[0].strip() or '0')
except ValueError:
qty = 0
price_raw = (params.get('price') or [''])[0].strip()
try:
price = int(price_raw) if price_raw else None
except ValueError:
price = None
if not account or side not in ('BUY', 'SELL') or not symbol or qty <= 0:
self._send_json(400, {'ok': False, 'error': 'account/side/symbol/qty required'})
return
if order_type not in ('LIMIT', 'MARKET'):
self._send_json(400, {'ok': False, 'error': 'order_type must be LIMIT|MARKET'})
return
if order_type == 'LIMIT' and (price is None or price <= 0):
self._send_json(400, {'ok': False, 'error': 'LIMIT requires positive price'})
return
try:
if not symbol_name:
import kiwoom_client as kc
try:
meta = kc.resolve_stock_code(symbol)
symbol_name = meta.get('name') or symbol
except Exception:
symbol_name = symbol
from orders import handler
# propose_trade 직접 호출 — 거부 메시지는 web 토스트로만 (텔레그램 중복 발송 X).
# 성공 시에만 카드+PIN을 텔레그램으로 발송 (사용자가 PIN 받아야 함).
res = handler.propose_trade(
account=account, side=side, symbol=symbol, symbol_name=symbol_name,
qty=qty, order_type=order_type,
price=(price if order_type == 'LIMIT' else None),
)
if res.get('ok'):
# 매수/매도 미리보기 카드 메시지·PIN 메시지 모두 텔레그램 발송 X.
# 텔레그램은 매매등록(submit_with_pin)·매매체결(fill_watcher) 시점만 발송.
# PIN은 iMessage로 iOS 자동입력 지원.
try:
from orders.pin import PinStore as _PinStore
_pending = _PinStore().peek()
if _pending and not _pending.consumed:
side_label = '매수' if side == 'BUY' else '매도'
handler.send_imessage_pin(
pin=_pending.pin,
card_id=_pending.card_id,
side_label=side_label,
symbol_name=symbol_name,
)
except Exception:
traceback.print_exc()
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': f'propose failed: {e}'})
return
status = 200 if res.get('ok') else 400
self._send_json(status, res)
return
if self.path == '/api/order/verify':
# 거래 모달 2단계: PIN 검증 → 키움 실주문 (dry_run=False).
pin = (params.get('pin') or [''])[0].strip()
if not pin:
self._send_json(400, {'ok': False, 'error': 'pin required'})
return
try:
from orders import handler
# submit_with_pin 직접 호출 — 거부 시 텔레그램 중복 발송 X.
# 성공·체결 시에만 텔레그램 결과 발송.
res = handler.submit_with_pin(pin, dry_run=False)
if res.get('ok') and res.get('message'):
try:
handler.send_telegram(res['message'], parse_mode='Markdown')
except Exception:
traceback.print_exc()
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': f'verify failed: {e}'})
return
status = 200 if res.get('ok') else 400
self._send_json(status, res)
return
if self.path == '/api/orders/cancel':
# 미체결 주문 취소 (kt10003). ord_no + account 필수.
ord_no = (params.get('ord_no') or [''])[0].strip()
account = (params.get('account') or [''])[0].strip()
if not ord_no or not account:
self._send_json(400, {'ok': False, 'error': 'ord_no/account required'})
return
try:
from orders import handler
res = handler.cancel_open_order(ord_no=ord_no, account=account)
# 매매체결(취소) 알림은 텔레그램으로 (handler.cancel_open_order는 send 안 함)
if res.get('ok') and res.get('message'):
try:
handler.send_telegram(res['message'], parse_mode=None)
except Exception:
traceback.print_exc()
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': f'open order cancel failed: {e}'})
return
self._send_json(200 if res.get('ok') else 400, res)
return
if self.path == '/api/order/cancel':
# 거래 모달 닫기 시 활성 카드 정리 (자동). 텔레그램 알림 X — 웹 자동 cancel은 사용자가 명시 액션 아님.
try:
from orders import handler
res = handler.cancel_active_card()
# cancel_active_card 반환은 PendingCard | None. dict로 변환.
if res is None:
self._send_json(200, {'ok': True, 'message': '활성 카드 없음'})
return
self._send_json(200, {'ok': True, 'message': '카드 취소됨', 'card_id': res.card_id})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': f'cancel failed: {e}'})
return
return
if self.path in wl_actions:
action = wl_actions[self.path]
if not stock:
self.send_error(400, 'Bad Request', explain='종목명 누락')
return
try:
_apply_watchlist_action(action, stock)
_invalidate_panels_cache()
except KeyError:
self.send_error(404, 'Not Found', explain=f'감시종목에 없음: {stock}')
return
except Exception as e:
traceback.print_exc()
self.send_error(500, 'Internal Error', explain=str(e))
return
sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} stock={stock} → ok\n')
sys.stdout.flush()
self.send_response(303)
self.send_header('Location', '/#tab-wl')
self.end_headers()
return
if self.path == '/alerts/reset':
if not stock:
self.send_error(400, 'Bad Request', explain='종목명 누락')
return
try:
_apply_alerts_reset(stock)
except Exception as e:
traceback.print_exc()
self.send_error(500, 'Internal Error', explain=str(e))
return
sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} stock={stock} → ok\n')
sys.stdout.flush()
self.send_response(303)
self.send_header('Location', '/#tab-wl')
self.end_headers()
return
if self.path in in_actions:
action = in_actions[self.path]
# add는 stock 또는 code 둘 중 하나면 OK — _apply_interests_action 안에서 검증.
if not stock and action != 'add':
if wants_json:
self._send_json(400, {'ok': False, 'error': '종목명 누락'})
else:
self.send_error(400, 'Bad Request', explain='종목명 누락')
return
payload = {k: (v[0] if v else '') for k, v in params.items() if k != 'stock'}
try:
_apply_interests_action(action, stock, payload)
_invalidate_panels_cache()
except MultipleMatchError as e:
cands = [{'name': c['name'], 'code': c['code']} for c in e.candidates]
if wants_json:
self._send_json(200, {'ok': False, 'error': 'multiple', 'query': e.query, 'candidates': cands})
else:
names = ', '.join(c['name'] for c in e.candidates[:5])
self.send_error(409, 'Conflict', explain=f'"{e.query}"와 일치하는 종목이 여러 개입니다. 후보: {names}')
return
except KeyError:
msg = f'관심종목에 없음: {stock}'
if wants_json:
self._send_json(404, {'ok': False, 'error': msg})
else:
self.send_error(404, 'Not Found', explain=msg)
return
except ValueError as e:
if wants_json:
self._send_json(400, {'ok': False, 'error': str(e)})
else:
self.send_error(400, 'Bad Request', explain=str(e))
return
except Exception as e:
traceback.print_exc()
if wants_json:
self._send_json(500, {'ok': False, 'error': str(e)})
else:
self.send_error(500, 'Internal Error', explain=str(e))
return
sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} stock={stock} → ok\n')
sys.stdout.flush()
if wants_json:
self._send_json(200, {'ok': True})
else:
self.send_response(303)
self.send_header('Location', '/#tab-interests')
self.end_headers()
return
if self.path == '/tags/set':
# 종목 공용 태그 — stock(name) 또는 code 둘 중 하나는 필수.
# tag(text)+leader 모두 비어/false면 항목 제거.
code = (params.get('code') or [''])[0].strip()
tag_raw = (params.get('tag') or [''])[0]
leader_raw = (params.get('leader') or ['0'])[0].strip().lower()
leader = leader_raw in ('1', 'true', 'on', 'yes')
if not stock and not code:
if wants_json:
self._send_json(400, {'ok': False, 'error': '종목 식별자 없음 (stock 또는 code 필요)'})
else:
self.send_error(400, 'Bad Request', explain='종목 식별자 없음')
return
try:
_set_stock_tag(code or None, stock or None, tag_raw, leader)
_invalidate_panels_cache()
except ValueError as e:
if wants_json:
self._send_json(400, {'ok': False, 'error': str(e)})
else:
self.send_error(400, 'Bad Request', explain=str(e))
return
except Exception as e:
traceback.print_exc()
if wants_json:
self._send_json(500, {'ok': False, 'error': str(e)})
else:
self.send_error(500, 'Internal Error', explain=str(e))
return
sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} stock={stock} code={code} tag={tag_raw!r} leader={leader} → ok\n')
sys.stdout.flush()
if wants_json:
self._send_json(200, {'ok': True})
else:
self.send_response(303)
self.send_header('Location', '/')
self.end_headers()
return
# /stock/<code>/generate — 새 보고서 큐 등록
if self.path.startswith('/stock/') and self.path.endswith('/generate'):
tail = self.path[len('/stock/'):-len('/generate')]
code = ''.join(ch for ch in tail.split('/')[0] if ch.isalnum())
if not code:
self.send_error(400, 'bad stock code')
return
try:
import stock_analysis as sa
result = sa.enqueue(code)
except Exception as e:
traceback.print_exc()
self.send_error(500, f'enqueue failed: {e}')
return
sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} code={code}{result["status"]}\n')
sys.stdout.flush()
self.send_response(303)
self.send_header('Location', f'/stock/{code}')
self.end_headers()
return
# /stock/<code>/peers/add | /peers/delete
if self.path.startswith('/stock/') and ('/peers/' in self.path):
tail = self.path[len('/stock/'):]
parts = tail.split('/')
code = ''.join(ch for ch in parts[0] if ch.isalnum())
action = parts[2] if len(parts) >= 3 else ''
if not code or action not in ('add', 'delete'):
self.send_error(400, 'bad peers route')
return
import stock_analysis as sa
if action == 'add':
query = (params.get('stock') or [''])[0].strip()
if not query:
self.send_error(400, 'stock empty')
return
from urllib.parse import quote as _urlquote
err_redirect: str | None = None
try:
p = sa.add_peer(code, query)
sys.stdout.write(f'[{self.log_date_time_string()}] POST peers/add code={code}{p["code"]} {p["name"]}\n')
except sa.MultipleMatchError as e:
names = ', '.join(c['name'] for c in e.candidates[:5])
err_redirect = f'"{e.query}"와 일치하는 종목이 여러 개예요. 더 정확히 입력해주세요. 후보: {names}'
except ValueError as e:
err_redirect = str(e)
except Exception as e:
traceback.print_exc()
err_redirect = f'추가 실패: {e}'
if err_redirect:
sys.stdout.flush()
self.send_response(303)
self.send_header('Location', f'/stock/{code}?err={_urlquote(err_redirect)}')
self.end_headers()
return
else: # delete
peer_code = (params.get('peer_code') or [''])[0].strip()
if not peer_code:
self.send_error(400, 'peer_code empty')
return
try:
sa.remove_peer(code, peer_code)
sys.stdout.write(f'[{self.log_date_time_string()}] POST peers/delete code={code}{peer_code}\n')
except Exception as e:
traceback.print_exc()
self.send_error(500, f'peers delete failed: {e}')
return
sys.stdout.flush()
self.send_response(303)
self.send_header('Location', f'/stock/{code}')
self.end_headers()
return
self.send_error(404)
def cmd_serve() -> int:
server = ThreadingHTTPServer((BIND_HOST, BIND_PORT), Handler)
print(f'serving on http://{BIND_HOST}:{BIND_PORT}', flush=True)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
return 0
def cmd_render(out: str | None) -> int:
body = render_html()
if out:
Path(out).write_text(body)
print(f'wrote {out} ({len(body)} bytes)')
else:
sys.stdout.write(body)
return 0
def main(argv: list[str]) -> int:
p = argparse.ArgumentParser(prog='behive_web')
sub = p.add_subparsers(dest='cmd', required=True)
sub.add_parser('serve')
pr = sub.add_parser('render')
pr.add_argument('--out')
args = p.parse_args(argv)
if args.cmd == 'serve':
return cmd_serve()
if args.cmd == 'render':
return cmd_render(args.out)
return 2
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))