#!/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 # 빌드 식별자 — 파일 mtime 기반, import 시 1회 계산. 코드 수정 후 데몬 재시작하면 값이 바뀐다. # 클라이언트가 shell 로드 시 저장한 __build 와 /api/panels 응답의 build 가 다르면 1회 자동 리로드 # → SPA식 fetch 갱신(location.reload 없음)이 옛 인라인 JS/CSS를 계속 쓰던 문제 해소. try: BUILD = str(int(Path(__file__).resolve().stat().st_mtime)) except Exception: BUILD = '0' WATCHLIST = WORKSPACE / 'state' / 'behive_watchlist.json' INTERESTS = WORKSPACE / 'state' / 'behive_interests.json' STOCK_TAGS = WORKSPACE / 'state' / 'behive_stock_tags.json' STOCK_NOTES = WORKSPACE / 'state' / 'behive_stock_notes.json' CHART_HLINES = WORKSPACE / 'state' / 'behive_chart_hlines.json' INTEREST_GROUPS = WORKSPACE / 'state' / 'behive_interest_groups.json' HOLDEVAL_SAVED = WORKSPACE / 'state' / 'behive_holdeval_saved.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')) sys.path.insert(0, str(WORKSPACE)) # sim 패키지(from sim import signals, config) import 용 _holidays_cache: dict = {'mtime': 0.0, 'set': set()} _traded_codes_cache: dict = {'mtime': 0.0, 'set': set()} _holding_since_cache: dict = {'mtime': None, 'map': {}} _quote_cache: dict[str, tuple[dict, float]] = {} _quote_cache_lock = threading.Lock() # 계좌별 미체결(ka10075) 캐시 — 거래 모달 max_qty 계산용. 미체결은 가격과 무관하므로 # 단가 keystroke마다 재조회하지 않도록 짧은 TTL로 묶는다. 주문 접수 후 프론트가 재조회하면 # 최대 TTL 만큼 stale할 수 있으나, propose/키움 서버가 최종 검증하므로 표시용으론 충분. OPEN_ORDERS_CACHE_TTL = 15.0 _open_orders_cache: dict[str, tuple[list, float]] = {} _open_orders_cache_lock = threading.Lock() def _cached_open_orders(account_label: str) -> list: """계좌 미체결 주문 목록 (ka10075) — TTL 캐시. 실패 시 빈 리스트.""" now = time.time() with _open_orders_cache_lock: hit = _open_orders_cache.get(account_label) if hit and (now - hit[1]) < OPEN_ORDERS_CACHE_TTL: return hit[0] try: import kiwoom_client as kc rows = kc.get_open_orders(account_label) except Exception: return [] with _open_orders_cache_lock: _open_orders_cache[account_label] = (rows, now) return rows def _pending_buy_lock(account_label: str) -> int: """계좌의 미체결 매수 지정가로 묶인 예수금 합계. d2_entra는 이 금액을 아직 포함하므로 실제 매수가능액 = d2_entra − 이 값. 시장가(price=0) 미체결은 금액 미상이라 제외(render 경로와 동일).""" total = 0 for o in _cached_open_orders(account_label): if o.get('side') == 'BUY': px = o.get('order_price') or 0 if px > 0: total += px * (o.get('unfilled_qty') or 0) return total def _pending_sell_qty(account_label: str, code: str) -> int: """계좌·종목의 미체결 매도 수량 합계.""" total = 0 for o in _cached_open_orders(account_label): if o.get('side') == 'SELL' and o.get('code') == code: total += o.get('unfilled_qty') or 0 return total # 기술적 권장가(매수/매도 판단 보조) 캐시 — 완성된 일봉 기반이라 장중 안정적. # 키: f'{code}:{side}:{avg_price}'. 현재가 대비 비교는 클라이언트가 실시간 시세로 처리. ADVICE_CACHE_TTL = 120.0 _advice_cache: dict[str, tuple[dict, float]] = {} _advice_cache_lock = threading.Lock() def _price_advice(code: str, side: str, avg_price: int | None = None) -> dict | None: """일봉 기반 기술적 권장 매수가/매도가. 근거는 이평·ATR만(sim.signals 규칙 재사용). 반환: {'levels': [{label, price, hint, tap}], 'atr','sma20','rsi','price','recent_low','recent_high'} 데이터 부족(신규주)·ETF 등으로 지표 산출 불가 시 None (예외 raise 안 함). ⚠️ 상수는 활성 sim.config 사용 — params.json 튜닝이 걸려있으면 그 값이 반영됨(현재는 문서화된 기본값). """ side = (side or '').upper() cache_key = f'{code}:{side}:{avg_price or 0}' now = time.time() with _advice_cache_lock: hit = _advice_cache.get(cache_key) if hit and (now - hit[1]) < ADVICE_CACHE_TTL: return hit[0] try: from sim import signals, config import daily_candles_cache as dcc candles = dcc.get_candles(code, count=250) ind = signals.compute_indicators(candles) if candles else None except Exception: traceback.print_exc() ind = None if not ind: with _advice_cache_lock: _advice_cache[cache_key] = (None, now) return None price = ind['price'] atr = ind['atr'] sma20 = ind['sma_long'] prev_close = ind['prev_close'] recent_high = ind['recent_high'] recent_low = ind['recent_low'] rsi = ind.get('rsi') levels: list[dict] = [] if side == 'BUY': pullback = int(round(max(sma20, prev_close - atr * config.PULLBACK_ATR_MULT))) p_stop, p_target = signals.compute_stop_target(pullback, atr, recent_low) levels.append({ 'label': '눌림목 매수가', 'price': pullback, 'tap': True, 'hint': f'20일선({sma20:,.0f})과 전일종가−ATR({prev_close - atr * config.PULLBACK_ATR_MULT:,.0f}) 중 높은 값. ' f'여기까지 눌리면 매수 검토(지정가로 걸어둠).', }) if recent_high: levels.append({ 'label': '돌파 기준가', 'price': int(recent_high), 'tap': True, 'hint': f'최근 {config.BREAKOUT_LOOKBACK}일 전고점. 이 위로 거래량 실려 뚫으면 돌파 매수 신호.', }) levels.append({ 'label': '(눌림목 매수 시) 예상 손절', 'price': p_stop, 'tap': False, 'hint': f'매수가−ATR×{config.STOP_ATR_MULT:g}와 최근 {config.SWING_LOW_LOOKBACK}일 저점 중 위쪽. 여기 깨지면 손절.', }) levels.append({ 'label': '(눌림목 매수 시) 목표가', 'price': p_target, 'tap': False, 'hint': f'손익비 {config.RR_RATIO:g}:1 — 손절폭의 {config.RR_RATIO:g}배 위. 1차 익절 목표.', }) else: # SELL — 보유 청산. 평단 고정은 고수익주에서 붕괴 → 지지·저항·트레일링으로. trail = int(round(price - atr * config.TRAIL_ATR_MULT)) levels.append({ 'label': '트레일링 손절', 'price': trail, 'tap': True, 'hint': f'현재가−ATR×{config.TRAIL_ATR_MULT:g}. 고점 대비 이만큼 되돌리면 익절/이탈(수익 보호).', }) levels.append({ 'label': '20일선 지지', 'price': int(round(sma20)), 'tap': True, 'hint': '20일 이동평균선. 추세 지지선 — 종가로 깨지면 상승추세 이탈 신호.', }) if recent_low: levels.append({ 'label': f'{config.SWING_LOW_LOOKBACK}일 스윙저점', 'price': int(recent_low), 'tap': True, 'hint': f'최근 {config.SWING_LOW_LOOKBACK}일 최저가. 이 아래로 밀리면 하락 전환 경계.', }) if recent_high: levels.append({ 'label': '전고점(저항/목표)', 'price': int(recent_high), 'tap': True, 'hint': f'최근 {config.BREAKOUT_LOOKBACK}일 전고점. 1차 저항 — 도달 시 분할 익절 고려.', }) # 평단 근처(±ATR)일 때만 평단 기준 손절선 추가 — 그 밖(고수익/큰손실)에선 무의미해 생략. if avg_price and abs(price - avg_price) <= atr: a_stop, _ = signals.compute_stop_target(int(avg_price), atr, recent_low) levels.append({ 'label': '평단 기준 손절', 'price': a_stop, 'tap': True, 'hint': f'평단({avg_price:,})−ATR×{config.STOP_ATR_MULT:g}와 스윙저점 중 위쪽. 본전 근처에서 손실 방어선.', }) result = { 'levels': levels, 'price': int(price), 'atr': int(round(atr)), 'sma20': int(round(sma20)), 'rsi': (round(rsi) if rsi is not None else None), 'recent_low': int(recent_low) if recent_low else None, 'recent_high': int(recent_high) if recent_high else None, } with _advice_cache_lock: _advice_cache[cache_key] = (result, now) return result 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()) def _holding_since_map() -> dict: """trade_journal.jsonl 재구성 → {code: 'YYYYMMDD'} 현재 연속 보유 시작일. 보유종목 '매수후 고점대비' 낙폭 계산용. mtime 기반 lazy reload. 계좌·owner 무관 code 단위 순매수 누적 — 보유수량이 0으로 떨어지면 streak 리셋, 재진입 시 마지막 streak 시작일. (드물게 본인·가희 동시보유 시 더 이른 날로 근사).""" if not TRADE_JOURNAL_FILE.exists(): return {} try: mtime = TRADE_JOURNAL_FILE.stat().st_mtime except OSError: return _holding_since_cache.get('map', {}) if mtime != _holding_since_cache.get('mtime'): try: events: dict[str, list[tuple[str, int]]] = {} for ln in TRADE_JOURNAL_FILE.read_text().splitlines(): ln = ln.strip() if not ln: continue try: d = json.loads(ln) except json.JSONDecodeError: continue c = d.get('code') if not c: continue day = (d.get('date') or '').replace('-', '') net = int(d.get('buy_qty') or 0) - int(d.get('sell_qty') or 0) events.setdefault(c, []).append((day, net)) out: dict[str, str] = {} for c, evs in events.items(): evs.sort(key=lambda x: x[0]) run = 0 start = None for day, net in evs: if run <= 0 and net > 0: start = day run += net if run <= 0: run = 0 start = None if run > 0 and start: out[c] = start _holding_since_cache['map'] = out _holding_since_cache['mtime'] = mtime except Exception as e: sys.stderr.write(f'holding_since load failed: {e}\n') return _holding_since_cache.get('map', {}) _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 _bump_panel_signal() -> None: """주문 접수/취소 등 이벤트 발생 시 state/fill_signal 갱신 → 브라우저 1초 틱(realtime)이 fill_seq 변화를 감지해 패널을 1회 갱신. 3초 주기 전면 swap 폐지(2026-07-22, 이벤트 기반 전환) 후 미체결·PIN 배지 신선도 유지용. fill_watcher 가 체결 시 쓰는 같은 파일. 실패 무시.""" try: (WORKSPACE / 'state' / 'fill_signal').write_text(str(time.time()), encoding='utf-8') except Exception: pass def _is_preopen_market_day() -> bool: """거래일 08:00 전. 장전 가격으로 당일 손익을 만들지 않는다.""" now = datetime.now(KST) return ( now.weekday() < 5 and now.hour < 8 and now.strftime('%Y-%m-%d') not in _load_holidays() ) 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['volume'] = snap.get('volume') out['_nxt_volume'] = snap.get('_nxt_volume', 0) 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 nxt_probe_codes = list(miss_codes) nxt_probe_indices = list(miss_indices) # WS 허브 연결 시 — 미스 종목 시세를 허브 메모리(0B 실시간, KRX+NXT 통합)에서 우선 충당. # 서버가 동일 렌더러로 OHLC를 그린다. 단, NXT 거래가능 초록점은 허브 exchange가 아니라 # 아래 ka10095 NX 단독 응답 가능 여부로만 판정한다. # 허브에 없는(아직 미체결) 종목만 ka10095 AL 폴백으로 남긴다. 캐시엔 안 넣음 — 매 페치 라이브 허브 사용. hub = _RT_HUB if hub is not None and hub.is_connected(): hub_q = hub.get_quotes(miss_codes) rest_codes: list[str] = [] rest_indices: list[int] = [] for idx, code in zip(miss_indices, miss_codes): q = hub_q.get(kc._clean_code(code)) price = (q or {}).get('price') or 0 if not price: rest_codes.append(code) rest_indices.append(idx) continue out = cards[idx] out['price'] = price out['day_change'] = q.get('change') out['day_change_pct'] = q.get('pct') out['open'] = q.get('open') or None out['high'] = q.get('high') or None out['low'] = q.get('low') or None out['volume'] = q.get('volume') out['_krx_price'] = price out['_nxt_price'] = 0 miss_codes, miss_indices = rest_codes, rest_indices batch: dict[str, dict] = {} if miss_codes: 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]) # NXT 비교용 batch — 마크(KRX/NXT) 판정에 사용. 실패해도 fallback X (마크만 KRX로 처리). # get_watchlist_quotes는 NXT 단독 suffix를 'NX'로 받는다. 'NXT'는 suffix 없이 KRX/기본값으로 # 떨어져 NXT 미거래 종목도 거래 가능처럼 보이므로 쓰면 안 된다. nxt_batch: dict[str, dict] = {} try: nxt_batch = kc.get_watchlist_quotes(nxt_probe_codes, exchange='NX') except Exception as ex: sys.stderr.write(f'ka10095 NXT batch 실패 (마크 판정용): {ex}\n') for idx, code in zip(nxt_probe_indices, nxt_probe_codes): nxt_q = nxt_batch.get(code) if nxt_batch else None cards[idx]['_nxt_price'] = (nxt_q or {}).get('price', 0) if nxt_q else 0 # NXT 거래량 — RVOL은 KRX 기준(일봉 평균과 동일)이라 KRX=AL−NXT 계산에 사용. cards[idx]['_nxt_volume'] = (nxt_q or {}).get('volume', 0) if nxt_q else 0 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 out['volume'] = q.get('volume') else: out['quote_error'] = '가격 0' # 마크 판정: NXT batch 가격 vs 기본 batch 가격 비교. nxt_price = out.get('_nxt_price', 0) or 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'], 'volume': out.get('volume'), '_nxt_volume': out.get('_nxt_volume', 0), '_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') out['volume'] = snap.get('volume') 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') out['volume'] = q.get('volume') 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'], 'volume': out.get('volume'), } 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 day_pl_reason = None if _is_preopen_market_day(): day_pl_total = None day_pl_reason = 'preopen' # 휴장일/주말은 시장 변동이 없어야 자연. 단 NXT 가격 보정으로 어제 스냅샷과 차이가 생겨 # day_pl_total이 0이 아닌 값을 갖게 됨 → 사용자 혼란 차단 위해 None으로 강제(미표시). if _market_phase_state().get('phase') in ('holiday', 'weekend'): day_pl_total = None day_pl_reason = 'closed' # 투자원금(누적 입금−출금) — 캐시 파일 읽기만 (키움 콜 0). # principal None = 미집계(부트스트랩 전) → KPI 2행 미표시. try: import investment_principal as ip pr = ip.get_principal(lbls) except Exception as e: sys.stderr.write(f'principal load failed: {e}\n') pr = {'principal': None, 'cash_in': None, 'cash_out': None, 'qty_flow': False, 'by_label': {}} owner_data[owner] = { 'labels': lbls, 'consolidated': consolidated, '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, 'day_pl_reason': day_pl_reason, '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, 'principal': pr['principal'], 'principal_cash_in': pr['cash_in'], 'principal_cash_out': pr['cash_out'], 'principal_qty_flow': pr['qty_flow'], 'principal_by_label': pr['by_label'], } # 당일정산(phantom) 종목에 현재가 주입 — 매도 시점과 시장 종가 비교용. # 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] = {} # KRX/NX 2콜은 가격 보정뿐 아니라 보유종목 행의 KRX·NXT 듀얼 OHLC 미니(r['ohlc_krx']·['ohlc_nxt']) # 데이터원이다. _AL 허브는 거래소별 단일 최신값만 들어 두 컬럼을 동시에 못 주므로 skip 불가 — WS 연결과 무관하게 호출. 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') # 당일 매수 동반 매도행의 pl을 '전일 보유 평단' 기준으로 재계산 (ka10170 round-trip 오표시 교정). # 전일 평단은 파일 이력 재생에서. raw 저장(record_trades) 직후라 prev_avg_map은 당일분 제외(date<오늘). try: _today_iso = datetime.now(tj.KST).strftime('%Y-%m-%d') _prev_avgs = tj.prev_avg_map(_today_iso) for _lb, _trs in journal_by_label.items(): for _t in _trs: _pa = _prev_avgs.get((_lb, _t.get('code')), 0) if (_t.get('sell_qty') or 0) > 0 and (_t.get('buy_qty') or 0) > 0 and _pa > 0: tj._apply_rebase(_t, float(_t.get('sell_avg') or 0), int(_t['sell_qty']), _pa) except Exception as e: sys.stderr.write(f'[journal rebase] 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 = {} # 투자원금 — 방금 받은 당일 입출금이 캐시에 미반영일 때만 올해 창 재조회(kt00016 라벨당 1콜). # 입출금 없는 평시엔 캐시 읽기만 하고 호출 0. 부트스트랩 전이면 needs_refresh가 False. try: import investment_principal as ip if ip.needs_refresh(labels, cash_flow_by_label): ip.refresh_current(labels, cash_flow_by_label) except Exception as e: sys.stderr.write(f'principal refresh failed: {e}\n') # 미체결 → owner별 + 전역 코드 keyed pending map # pending_by_owner[owner][code] = {'buy_qty': N, 'sell_qty': M, 'orders': [...]} # pending_global[code] = {'buy_qty': N, 'sell_qty': M, 'orders': [...]} # 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', ''), # 스톱지정가 조건단가 (ka10075 stop_pric) + 트레일링 예약이면 그 상태. # 상세 표시가 "지정가"로 뭉개지지 않게 주문 성격을 그대로 실어 보낸다. 'stop_price': o.get('stop_price', 0), 'trailing': _trailing_info_for(o.get('ord_no', '')), '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), 'volume': qk.get('volume', 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), 'volume': qn.get('volume', 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) _MARKET_BY_CODE_CACHE = None def _market_label_html(code: str) -> str: """종목코드 → 시장(코스피/코스닥) 작은 배지 HTML. 미상·ETF 등이면 ''. stock_codes.json(name→{code,market})을 code→market로 뒤집어 프로세스 1회 캐시.""" global _MARKET_BY_CODE_CACHE code = (code or '').strip() if not code: return '' if _MARKET_BY_CODE_CACHE is None: import kiwoom_client as kc _MARKET_BY_CODE_CACHE = { info['code']: info['market'] for info in kc._load_code_cache().values() if info.get('code') and info.get('market') } mk = _MARKET_BY_CODE_CACHE.get(code) if mk == 'KOSPI': return 'KOSPI' if mk == 'KOSDAQ': return 'KOSDAQ' return '' 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'{chip_text}' 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'' ) 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'' ) 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) # ---- 트레일링 계단 프리셋 (읽기 전용 · 고정 4종) ---- # 계단 = [{'pct': 고점대비 하락률, 'cum': 그때까지 팔려 있어야 할 누적 비중}] # 누적으로 적는 이유는 관리자님이 그렇게 생각하기 때문이다("30% 빠지면 전량") — # 주문 수량인 추가분(20/30/50)으로의 환산은 orders/trailing.normalize_steps 가 한다. # 순서 = 기본값이 맨 앞(단일) + 트레일 폭이 좁은 것부터. 화면에서 고른 뒤 그 자리에서 # 고칠 수 있으므로 저장 기능은 두지 않는다(2026-07-31 관리자님 지시로 제거). TRAIL_PRESETS = [ {'name': '단일', 'steps': [{'pct': 10, 'cum': 100}]}, {'name': '보수', 'steps': [{'pct': 5, 'cum': 30}, {'pct': 10, 'cum': 70}, {'pct': 15, 'cum': 100}]}, {'name': '표준', 'steps': [{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50}, {'pct': 30, 'cum': 100}]}, {'name': '느슨', 'steps': [{'pct': 15, 'cum': 25}, {'pct': 25, 'cum': 50}, {'pct': 30, 'cum': 100}]}, ] def _stock_notes_lock(): """STOCK_NOTES 파일 직렬화 — _stock_tags_lock 과 동일 패턴, 별도 lock 파일.""" import fcntl as _fcntl from contextlib import contextmanager as _cm @_cm def _ctx(): lock_path = STOCK_NOTES.with_suffix(STOCK_NOTES.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_notes() -> dict: """{'by_code': {code: text}, 'by_name': {name: text}}. 종목별 자유 메모 저장소.""" if not STOCK_NOTES.exists(): return {'by_code': {}, 'by_name': {}} try: d = json.loads(STOCK_NOTES.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_note(code: str | None, name: str | None) -> str: """메모 텍스트 반환. 없으면 ''. code 우선 → name fallback.""" if not code and not name: return '' notes = _load_stock_notes() for src_key, key in (('by_code', code), ('by_name', name)): if not key: continue v = notes.get(src_key, {}).get(key) if isinstance(v, str) and v.strip(): return v.strip() return '' def _set_stock_note(code: str | None, name: str | None, text: str) -> None: """메모 저장. 빈 텍스트면 항목 제거. 1000자 cap.""" text = (text or '').strip()[:1000] code = (code or '').strip() or None name = (name or '').strip() or None if not code and not name: raise ValueError('종목코드 또는 종목명 중 하나는 필요합니다') STOCK_NOTES.parent.mkdir(parents=True, exist_ok=True) with _stock_notes_lock(): d = _load_stock_notes() if text: if code: d['by_code'][code] = text if name: d['by_name'][name] = text else: if code: d['by_code'].pop(code, None) if name: d['by_name'].pop(name, None) tmp = STOCK_NOTES.with_suffix('.json.tmp') tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2)) tmp.replace(STOCK_NOTES) def _holdeval_lock(): """HOLDEVAL_SAVED 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일.""" import fcntl as _fcntl from contextlib import contextmanager as _cm @_cm def _ctx(): lock_path = HOLDEVAL_SAVED.with_suffix(HOLDEVAL_SAVED.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_holdeval_saved() -> list: """보유 평가 계산기 저장 목록. [{id, owner, code, name, avg, qty}] 리스트.""" if not HOLDEVAL_SAVED.exists(): return [] try: d = json.loads(HOLDEVAL_SAVED.read_text()) return d if isinstance(d, list) else [] except Exception: return [] def _save_holdeval_saved(items: list) -> None: HOLDEVAL_SAVED.parent.mkdir(parents=True, exist_ok=True) tmp = HOLDEVAL_SAVED.with_suffix('.json.tmp') tmp.write_text(json.dumps(items, ensure_ascii=False, indent=2)) tmp.replace(HOLDEVAL_SAVED) def _add_holdeval_saved(owner: str, code: str, name: str, avg: float, qty: float) -> list: """저장 항목 추가. code·avg·qty 필수. 새 id 부여 후 갱신된 목록 반환.""" import uuid owner = (owner or '').strip()[:40] code = ''.join(ch for ch in (code or '').strip() if ch.isalnum()) name = (name or '').strip()[:40] if not code: raise ValueError('종목을 먼저 검색·선택하세요') if avg <= 0 or qty <= 0: raise ValueError('평단가·주수는 0보다 커야 합니다') with _holdeval_lock(): items = _load_holdeval_saved() items.append({'id': uuid.uuid4().hex[:8], 'owner': owner, 'code': code, 'name': name, 'avg': avg, 'qty': qty}) _save_holdeval_saved(items) return items def _delete_holdeval_saved(entry_id: str) -> list: """id로 저장 항목 삭제. 갱신된 목록 반환.""" with _holdeval_lock(): items = [it for it in _load_holdeval_saved() if it.get('id') != entry_id] _save_holdeval_saved(items) return items def _update_holdeval_saved(entry_id: str, owner: str, avg: float, qty: float, code: str = '', name: str = '') -> list: """id로 저장 항목 수정 (물타기·불타기 후 평단가·주수 갱신). owner·avg·qty 필수. code·name은 넘어오면 교체, 비면 기존 유지. 갱신된 목록 반환.""" owner = (owner or '').strip()[:40] code = ''.join(ch for ch in (code or '').strip() if ch.isalnum()) name = (name or '').strip()[:40] if avg <= 0 or qty <= 0: raise ValueError('평단가·주수는 0보다 커야 합니다') with _holdeval_lock(): items = _load_holdeval_saved() found = False for it in items: if it.get('id') == entry_id: it['owner'] = owner it['avg'] = avg it['qty'] = qty if code: it['code'] = code if name: it['name'] = name found = True break if not found: raise ValueError('항목을 찾을 수 없습니다') _save_holdeval_saved(items) return items def _note_button_html(code: str, name: str) -> str: """detail actions의 '메모' 버튼. 클릭 시 note-modal 열리며 현재 메모가 prefill. 메모가 있으면 ● 표시 + has-note 클래스로 강조.""" code_s = (code or '').strip() name_s = (name or '').strip() if not code_s and not name_s: return '' text = _get_stock_note(code_s, name_s) code_attr = html.escape(code_s, quote=True) name_attr = html.escape(name_s, quote=True) has = bool(text) cls = 'btn-note has-note' if has else 'btn-note' label = '📝 메모 ●' if has else '📝 메모' title = html.escape(text[:60], quote=True) if has else '메모 작성' return ( f'' ) def _chart_hlines_lock(): """CHART_HLINES 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일.""" import fcntl as _fcntl from contextlib import contextmanager as _cm @_cm def _ctx(): lock_path = CHART_HLINES.with_suffix(CHART_HLINES.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() _HLINES_MAX = 30 # 종목당 수평선 개수 상한 def _load_chart_hlines() -> dict: """{code: [price, ...]}. 종목차트에 그릴 사용자 수평선(지지/저항 등) 저장소. 종목코드 단위 공용(계좌 무관).""" if not CHART_HLINES.exists(): return {} try: d = json.loads(CHART_HLINES.read_text()) return d if isinstance(d, dict) else {} except Exception: return {} def _get_chart_hlines(code: str | None) -> list: """종목의 수평선 가격 리스트(정수, 내림차순). 없으면 [].""" code = (code or '').strip() if not code: return [] v = _load_chart_hlines().get(code) if not isinstance(v, list): return [] out = set() for x in v: try: p = int(round(float(x))) if p > 0: out.add(p) except (TypeError, ValueError): pass return sorted(out, reverse=True) def _add_chart_hline(code: str | None, price) -> None: """수평선 가격 추가. 중복은 무시. 상한(30) 초과 시 ValueError.""" code = (code or '').strip() if not code: raise ValueError('종목코드가 필요합니다') try: p = int(round(float(price))) except (TypeError, ValueError): raise ValueError('가격이 올바르지 않습니다') if p <= 0: raise ValueError('가격은 0보다 커야 합니다') CHART_HLINES.parent.mkdir(parents=True, exist_ok=True) with _chart_hlines_lock(): d = _load_chart_hlines() cur = _normalize_hline_list(d.get(code)) if p not in cur and len(cur) >= _HLINES_MAX: raise ValueError(f'수평선은 최대 {_HLINES_MAX}개까지 등록할 수 있습니다') cur.add(p) d[code] = sorted(cur, reverse=True) _write_chart_hlines(d) def _remove_chart_hline(code: str | None, price) -> None: """수평선 가격 제거. 리스트가 비면 종목 키 자체 삭제.""" code = (code or '').strip() if not code: raise ValueError('종목코드가 필요합니다') try: p = int(round(float(price))) except (TypeError, ValueError): raise ValueError('가격이 올바르지 않습니다') with _chart_hlines_lock(): d = _load_chart_hlines() cur = _normalize_hline_list(d.get(code)) cur.discard(p) if cur: d[code] = sorted(cur, reverse=True) else: d.pop(code, None) _write_chart_hlines(d) def _normalize_hline_list(v) -> set: out = set() if isinstance(v, list): for x in v: try: p = int(round(float(x))) if p > 0: out.add(p) except (TypeError, ValueError): pass return out def _write_chart_hlines(d: dict) -> None: tmp = CHART_HLINES.with_suffix('.json.tmp') tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2)) tmp.replace(CHART_HLINES) def _chart_hlines_ui(code: str, hlines: list) -> str: """차트 하단 수평선 UI — 컴팩트 행(등록된 선 색상 칩 + '+' 버튼). '+' 클릭 시 추가·삭제 모달. 색상은 정렬 인덱스로 SVG와 동일 매핑. /api/chart_svg 응답 HTML 블록에 포함돼 모든 차트(보유·관심·감시)에 동일하게 노출.""" code_s = (code or '').strip() if not code_s: return '' import stock_analysis as sa colors = getattr(sa, 'HLINE_COLORS', None) or ['#e879f9'] code_attr = html.escape(code_s, quote=True) if hlines: chips = ''.join( f'{p:,}' for i, p in enumerate(hlines) ) manage = ''.join( f'
' f'' f'{p:,}' f'' f'
' for i, p in enumerate(hlines) ) else: chips = '수평선 없음' manage = '
등록된 수평선이 없어요
' return ( f'
' '
' f'
{chips}
' '' '
' '' '
' ) def _interest_groups_lock(): """INTEREST_GROUPS 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일.""" import fcntl as _fcntl from contextlib import contextmanager as _cm @_cm def _ctx(): lock_path = INTEREST_GROUPS.with_suffix(INTEREST_GROUPS.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_interest_groups() -> dict: """관심종목 전용 그룹 레지스트리. {'groups': [순서있는 이름], 'members': {종목명: [그룹명]}}. stock_tags 와 독립. 멤버십 키는 종목명(interests 저장소가 종목명 키라 안정적).""" if not INTEREST_GROUPS.exists(): return {'groups': [], 'members': {}} try: d = json.loads(INTEREST_GROUPS.read_text()) if not isinstance(d.get('groups'), list): d['groups'] = [] if not isinstance(d.get('members'), dict): d['members'] = {} return d except Exception: return {'groups': [], 'members': {}} def _save_interest_groups_locked(d: dict) -> None: """atomic tmp write + replace. 반드시 _interest_groups_lock() 안에서 호출.""" INTEREST_GROUPS.parent.mkdir(parents=True, exist_ok=True) tmp = INTEREST_GROUPS.with_suffix('.json.tmp') tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2)) tmp.replace(INTEREST_GROUPS) def _get_interest_group_names(stock: str) -> list[str]: """해당 종목이 속한 그룹명 리스트. 없으면 [].""" stock = (stock or '').strip() if not stock: return [] members = _load_interest_groups().get('members', {}) v = members.get(stock) return list(v) if isinstance(v, list) else [] def _create_interest_group(name: str) -> None: """새 그룹 등록. trim·20자 cap. 빈/중복은 무시.""" name = (name or '').strip().lstrip('#').strip()[:20] if not name: return with _interest_groups_lock(): d = _load_interest_groups() if name not in d['groups']: d['groups'].append(name) _save_interest_groups_locked(d) def _rename_interest_group(old: str, new: str) -> None: """그룹 이름 변경. groups 위치 보존 + members 전 리스트에서 old→new 치환.""" old = (old or '').strip() new = (new or '').strip().lstrip('#').strip()[:20] if not old or not new or old == new: return with _interest_groups_lock(): d = _load_interest_groups() if old not in d['groups']: return # groups: 위치 보존 교체 (new 이미 있으면 중복 제거) d['groups'] = [new if g == old else g for g in d['groups']] seen: list[str] = [] for g in d['groups']: if g not in seen: seen.append(g) d['groups'] = seen # members: 각 리스트 old→new, 중복 제거 for stock, names in list(d['members'].items()): if old in names: repl = [new if n == old else n for n in names] dedup: list[str] = [] for n in repl: if n not in dedup: dedup.append(n) d['members'][stock] = dedup _save_interest_groups_locked(d) def _delete_interest_group(name: str) -> None: """그룹 삭제. groups 에서 제거 + members 전 리스트에서 strip, 빈 리스트 키 삭제.""" name = (name or '').strip() if not name: return with _interest_groups_lock(): d = _load_interest_groups() d['groups'] = [g for g in d['groups'] if g != name] for stock, names in list(d['members'].items()): if name in names: remain = [n for n in names if n != name] if remain: d['members'][stock] = remain else: d['members'].pop(stock, None) _save_interest_groups_locked(d) def _reorder_interest_groups(ordered: list[str]) -> None: """표시 순서 재배치. 기존과 동일한 이름집합만 허용(누락분은 기존 순서로 뒤에 append).""" with _interest_groups_lock(): d = _load_interest_groups() existing = d['groups'] new_order = [g for g in ordered if g in existing] for g in existing: if g not in new_order: new_order.append(g) d['groups'] = new_order _save_interest_groups_locked(d) def _set_interest_groups(stock: str, names: list[str]) -> None: """종목의 그룹 소속 편집. groups 에 존재하는 이름만 반영. 빈이면 members 키 삭제.""" stock = (stock or '').strip() if not stock: raise ValueError('종목명이 필요합니다') with _interest_groups_lock(): d = _load_interest_groups() valid = [n for n in names if n in d['groups']] dedup: list[str] = [] for n in valid: if n not in dedup: dedup.append(n) if dedup: d['members'][stock] = dedup else: d['members'].pop(stock, None) _save_interest_groups_locked(d) def _interest_group_button_html(stock: str) -> str: """관심종목 행의 '그룹' 버튼. 클릭 시 group-assign-modal 열림. 현재 소속 + 전체 그룹 목록을 data 속성으로 실어보냄. 디자인은 +태그 버튼(btn-add-tag)과 동일.""" name_s = (stock or '').strip() if not name_s: return '' current = _get_interest_group_names(name_s) all_groups = _load_interest_groups().get('groups', []) name_attr = html.escape(name_s, quote=True) cur_attr = html.escape(','.join(current), quote=True) all_attr = html.escape(','.join(all_groups), quote=True) return ( f'' ) 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) if action == 'delete': # 삭제 성공 후 그룹 멤버십 연쇄 정리. _interests_lock 밖에서 별도 파일 락 → 데드락 회피. try: _set_interest_groups(stock, []) except Exception: pass 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'
{html.escape(raw)}
') for lv in levels: mark = ' 알림됨' if f'buy@{lv}' in alerted else '' parts.append(f'
{lv:,}원{mark}
') return ''.join(parts) def _vol_surge_badge(code: str, today_volume) -> str: """오늘 거래량이 직전 20거래일 평균 대비 1.5배 이상이면 🔥 배지(배수 표기). - 20일 평균: dcc._select_latest(code, 21) 순수 SQLite 읽기(렌더 중 키움 호출 X). - 장중 환산: sa.session_vol_frac로 정규장 오늘 누적 거래량을 하루치로 투영. - 데이터 부족(차트 미조회로 캐시 없음 등)·임계 미만이면 '' 반환. """ if not code or not today_volume: return '' try: import daily_candles_cache as dcc import stock_analysis as sa today = datetime.now(KST).strftime('%Y%m%d') vols = [b['volume'] for b in dcc._select_latest(code, 21) if str(b.get('date') or '') != today and (b.get('volume') or 0) > 0] if len(vols) < 5: return '' avg20 = sum(vols[:20]) / min(len(vols), 20) if avg20 <= 0: return '' frac = sa.session_vol_frac([{'date': today}]) proj = (today_volume / frac) if frac else today_volume rvol = proj / avg20 except Exception: return '' if rvol < 1.5: return '' level = 'red' if rvol >= 3 else 'warm' return f'🔥{rvol:.1f}' 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'' if tier else '' if price is None: price_cell = '-' diff_cell = f'{html.escape(err) if err else "조회 불가"}' else: # 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음. # 정규장: KRX+NXT 모두 거래 → 초록점 # NXT 시간대: NXT 거래되는 종목(_nxt_price 살아있음)만 초록점 # 장마감·휴장·주말: 점 없음 nxt_p = c.get('_nxt_price', 0) or 0 _phase = _market_phase_state()['phase'] if _phase == 'regular': _tradable = True elif _phase == 'nxt': _tradable = nxt_p > 0 else: _tradable = False mark_html = '' if _tradable else '' # 미리보기 등락은 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:,}{d_sign}{day_pct:.2f}%' else: price_cell = f'{mark_html}{price:,}' if mode == 'watching' and isinstance(ref_price, (int, float)) and ref_price > 0: diff_cell = f'매수가 {ref_price:,.0f}' else: diff_cell = '' if mode == 'held': qty = c.get('held_qty', 0) status_cell = f'📦 {qty:,}' accounts = c.get('held_accounts') or [] avg = c.get('held_avg_price', 0) held_row = ( f'매입평단' f'{avg:,}원 · {", ".join(accounts)}' ) else: status_cell = '' 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'알림 {"·".join(_alert_kinds)}' 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 += ' 알림됨' 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 += ' 알림됨' chart_block = '' flow_block = '' if code: chart_block = f'
' # 외국인·기관·개인 수급 (ka10059) — 팝업 열 때 client lazy fetch (flow_script) flow_block = f'
' summary_lines = c.get('summary') or [] summary_block = '' if summary_lines: items = ''.join(f'
  • {html.escape(s)}
  • ' for s in summary_lines) summary_block = f'

    주요내용

    ' notes_lines = c.get('notes') or [] notes_block = '' if notes_lines: items = ''.join(f'
  • {html.escape(n)}
  • ' for n in notes_lines) notes_block = f'

    기타

    ' video = c.get('video') or {} video_block = '' if video.get('url'): title = html.escape(video.get('title', '영상')) video_block = ( f'

    출처

    ' f'{title} ↗' f'
    ' ) 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'' ) if has_trades else '' if source == 'watchlist' and raw_code: analyze_btn = ( f'' ) else: # 📊 분석은 상세(기업정보) 팝업 안으로 이동 — 카드 actions에선 미노출. analyze_btn = '' trade_mark = ( f'' ) if has_trades and source == 'interests' else '' # 매매 진입 버튼 — 항상 매수 default (오타 방지). 모달에서 매도로 토글 가능. order_side = 'BUY' order_btn = ( f'' ) if raw_code else '' # +태그 버튼 — held 행과 동일하게 펼침 상세 종목명 줄 오른쪽에 둔다 (actions 아님). tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '') # 관심종목은 그룹 버튼도 +태그 옆에 (actions 아님). group_add_btn = _interest_group_button_html(c.get('stock') or '') if source == 'interests' else '' detail_name_html = '' if not is_pending: code_span = f'{code}' if code else '' detail_name_html = f'
    {stock}{code_span}{tag_add_btn}{group_add_btn}
    ' # 현재가 아래 메타 라인 — 관심·감시는 비중·계좌 없어 시장(KOSPI/KOSDAQ)만. _mkt_label = _market_label_html(code) market_meta_html = f'
    {_mkt_label}
    ' if _mkt_label 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)}"' ) note_btn = _note_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 = ( '
    ' f'{note_btn}' f'{analyze_btn}' f'{info_btn}' f'{trade_btn}' f'{order_btn}' f'' '
    ' ) elif is_pending: actions_html = ( '
    ' f'삭제예정 {pending_at}' f'
    ' f'
    ' '
    ' ) cls = f'{cls} trash' else: wl_note_btn = _note_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 = ( '
    ' f'{wl_note_btn}' f'{analyze_btn}' f'{wl_info_btn}' f'{trade_btn}' f'{order_btn}' f'
    ' f'
    ' '
    ' ) tag_chip = _tag_chip_html(c.get('code') or '', c.get('stock') or '', interactive=True) row_key = code or stock return f'''
    {dot_html}{stock}{tag_chip}{status_cell}{trade_mark}{alert_badge}{_vol_surge_badge(code, (max(0, (c.get('volume') or 0) - (c.get('_nxt_volume') or 0)) or None))}
    {price_cell}
    {f'
    {diff_cell}
    ' if diff_cell else ''}
    {detail_name_html} {f'
    현재가{price_cell}
    ' if price is not None else ''} {market_meta_html} {'
    최초 알림 완료 — 리셋 전까지 매수 알림 없음
    ' if unheld_done else ''}
    {held_row} 매수가{buy_raw} 목표가{target_raw} 손절가{stop_raw} 저장일{saved}
    {ohlc_mini_html} {_pending_detail_html(c)} {summary_block} {notes_block} {video_block} {actions_html} {flow_block} {chart_block}
    ''' 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' # 이 라벨 행 아래에 구분선을 넣는다 (KPI 카드 공용). 라벨 기준이라 순서를 바꿔도 따라간다. # 총수익 = 전체 요약 묶음(총자산·투자원금·총수익)의 끝. _KPI_SEP_AFTER = {'총수익'} def _urlq(s: str) -> str: """쿼리스트링용 URL 인코딩 (owner 이름이 한글이라 필요).""" from urllib.parse import quote return quote(s, safe='') def _kpi_more_button(src: str, title: str) -> str: """KPI 라벨 옆 [!] 버튼 — kpi-modal 을 열고 src를 fetch해 본문을 innerHTML. 값이 아니라 라벨(th) 쪽에 붙는다 — 금액 뒤에 두면 숫자 우측정렬이 흐트러진다. src는 `{'html': '...'}` 를 주는 엔드포인트 (본문 조립은 서버 몫, JS는 삽입만). """ s = html.escape(src, quote=True) t = html.escape(title, quote=True) return (f'') def _acct_short(label: str) -> str: """모달 안에서는 owner가 이미 정해져 있으니 '가희_' 접두를 떼고 상품만 보인다.""" return label[3:] if label.startswith('가희_') else label def _render_principal_body(owner: str) -> str: """투자원금 팝업 본문 — 요약 + 입출금 내역. 캐시만 읽어 키움 콜 0. 내역은 kt00015 tp='1' 전체인데 대부분이 배당·이자라 실제 입금이 묻힌다 → 기본은 원금 반영분만 보이고, 체크박스로 나머지까지 펼친다(CSS-only, JS 없음). """ import investment_principal as ip from stock_portfolio_report import derive_owner import kiwoom_client as kc labels = [a['label'] for a in kc.list_accounts() if derive_owner(a['label']) == owner] pr = ip.get_principal(labels) if pr['principal'] is None: return '
    투자원금이 아직 집계되지 않았어요.
    ' fl = ip.get_flows(labels) def srow(label: str, value: str, cls: str = '') -> str: return (f'
    {html.escape(label)}' f'{value}
    ') head = [ srow('투자원금', f'{pr["principal"]:,}원', ' strong'), srow('누적 입금', f'{pr["cash_in"]:,}원'), srow('누적 출금', f'{pr["cash_out"]:,}원'), ] if len(pr['by_label']) > 1: head.append('
    계좌별
    ') head += [srow(_acct_short(lb), f'{v:,}원') for lb, v in pr['by_label'].items()] # 계좌 간 대체(내 일반↔ISA)는 owner 합계에선 상쇄되지만 계좌별 수치는 밀어놓는다. # 합계는 정확하니 계좌별만 오해하지 않게 알린다. if any('대체' in (r['rmrk'] or '') for r in fl['rows']): head.append('
    계좌 간 이동(대체)이 있어 계좌별 금액은 ' '실제 외부 입금과 다를 수 있어요. 합계는 정확합니다.
    ') if pr['qty_flow']: head.append('
    ⚠️ 주식 입출고(타사대체) 있음 — ' '현금 기준 원금이 실제보다 작을 수 있어요.
    ') if not fl['reconciled']: head.append('
    ⚠️ 분류되지 않은 새 적요가 있어요 — ' '내역 합계와 원금이 어긋날 수 있습니다.
    ') if not fl['available']: return (f'
    {"".join(head)}
    ' f'
    입출금 내역은 아직 수집되지 않았어요.
    ') n_pr = sum(1 for r in fl['rows'] if r['principal']) n_ot = len(fl['rows']) - n_pr items = [] for r in fl['rows']: sign = '+' if r['io'] == 'IN' else '−' acls = 'in' if r['io'] == 'IN' else 'out' dt = r['date'] dsp = f'{dt[:4]}.{dt[4:6]}.{dt[6:8]}' if len(dt) == 8 else dt items.append( f'
    ' f'{html.escape(dsp)}' f'{html.escape(r["rmrk"] or "-")}' f'{html.escape(_acct_short(r["label"]))}' f'{sign}{r["amount"]:,}' f'
    ' ) other_note = '' if fl['other_in'] or fl['other_out']: parts = [] if fl['other_in']: parts.append(f'입금 {fl["other_in"]:,}원') if fl['other_out']: parts.append(f'출금 {fl["other_out"]:,}원') other_note = (f'
    배당·이자 등 {n_ot}건 ({" · ".join(parts)})은 ' f'투자 성과라서 원금에 넣지 않아요.
    ') return ( f'
    {"".join(head)}
    ' f'{other_note}' f'
    입출금 내역 · 원금 {n_pr}건
    ' f'' f'' f'
    {"".join(items)}
    ' ) def _render_cashflow_body(owner: str) -> str: """당일 입출금 팝업 본문 — 입금/출금 합계 + 건별 내역 (kt00015 tp='1' 당일). 렌더 경로가 이미 부르는 조회지만 팝업은 드물게 열리므로 그때 다시 부른다(계좌당 1콜). """ from stock_portfolio_report import derive_owner import kiwoom_client as kc labels = [a['label'] for a in kc.list_accounts() if derive_owner(a['label']) == owner] by_label = kc.get_cash_flow_all(None, labels) rows = [{**f, 'label': lb} for lb, fl in by_label.items() for f in (fl or [])] if not rows: return '
    오늘 입출금이 없어요.
    ' rows.sort(key=lambda r: (r.get('time') or '', r['label'])) cash_in = sum(r['amount'] for r in rows if r['io_tp'] == 'IN') cash_out = sum(r['amount'] for r in rows if r['io_tp'] == 'OUT') def srow(label: str, value: str, cls: str = '') -> str: return f'
    {html.escape(label)}{value}
    ' head = [] if cash_in: head.append(srow('입금', f'{cash_in:,}원')) if cash_out: head.append(srow('출금', f'{cash_out:,}원')) # 순액은 양방향 다 있을 때만 — 한쪽뿐이면 위 합계와 같은 값이다. if cash_in and cash_out: net = cash_in - cash_out head.append(srow('순 입출금', f'{net:+,}원', ' strong')) items = [] for r in rows: sign = '+' if r['io_tp'] == 'IN' else '−' acls = 'in' if r['io_tp'] == 'IN' else 'out' tm = (r.get('time') or '')[:5] items.append( f'
    ' f'{html.escape(tm)}' f'{html.escape(r.get("rmrk") or "-")}' f'{html.escape(_acct_short(r["label"]))}' f'{sign}{r["amount"]:,}' f'
    ' ) return ( f'
    {"".join(head)}
    ' f'
    내역 · {len(rows)}건
    ' f'
    {"".join(items)}
    ' ) def _render_kpi_modal() -> str: """KPI 상세 공용 팝업 (투자원금·당일 입출금 등). shell HTML 직속이라 panels swap 영향 없음. 라벨 옆 [!] 클릭 → 버튼의 data-kpi-src fetch → 본문 innerHTML.""" return ( '' ) 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 d.get('day_pl_reason') == 'preopen': day_pl_html = '장 시작 전' # 휴장일/주말은 시장 변동이 없는 정상 상태 — "전날 스냅샷 없음"과 구분된 문구. elif _market_phase_state().get('phase') in ('holiday', 'weekend'): day_pl_html = '휴장 — 변동 없음' else: day_pl_html = '전날 스냅샷 없음' else: dcls = _profit_class(day_pl_total) dsign = '+' if day_pl_total >= 0 else '' prev_net = d.get('prev_net') or 0 pct_html = '' if prev_net: pct = (day_pl_total / prev_net) * 100 pct_html = f' ({dsign}{pct:.2f}%)' day_pl_html = f'{dsign}{day_pl_total:,}원{pct_html}' deposit_pct_html = '' if d['total_value']: dep_pct = (d['deposit'] / d['total_value']) * 100 deposit_pct_html = f'({dep_pct:.1f}%) ' locked = d.get('pending_buy_locked', 0) or 0 deposit_locked_html = ( f' · 매수 묶임 {locked:,}원' if locked > 0 else '' ) # 구분 없는 단일 목록 (관리자님 지정 순서). # 라벨 규칙: '총'은 계좌 전체(현금 포함) 기준일 때만 쓴다. # 총자산 = 평가금액 + 예수금 / 총수익 = 총자산 − 투자원금 # 평가금액·매입금액·평가손익은 보유종목 한정이라 '총'을 붙이지 않는다. # 맨 위는 계좌 전체 요약 3행 — 총자산(가진 돈) / 투자원금(넣은 돈) / 총수익(그 차이). # 아래 구분선은 이 묶음 끝(총수익)에 붙는다. 원금 미집계면 뒤 두 행과 선이 함께 사라진다. principal = d.get('principal') kpis = [ ('총자산', f'{d["total_net"]:,}원'), ] if principal: total_ret = d['total_net'] - principal tcls = _profit_class(total_ret) tsign = '+' if total_ret >= 0 else '' tpct = total_ret / principal * 100 # 값은 금액만, 상세는 라벨 옆 [!] 버튼 → 팝업. (3번째 원소 = 라벨 뒤 HTML) kpis.append(('투자원금', f'{principal:,}원', _kpi_more_button(f'/api/principal?owner={_urlq(owner)}', '투자원금'))) kpis.append(('총수익', f'{tsign}{total_ret:,}원 ({tsign}{tpct:.2f}%)')) # 당일 입출금은 예수금 바로 아래 (현금 잔액 ↔ 그날 현금 이동). 없으면 행 자체가 없다. cash_in = d.get('cash_in', 0) cash_out = d.get('cash_out', 0) cashflow_row = None if cash_in or cash_out: net = cash_in - cash_out ncls = _profit_class(net) nsign = '+' if net >= 0 else '' # 값은 순액만 — 입금/출금 쪼갠 내역과 건별 목록은 라벨 옆 [!] 팝업으로. cashflow_row = ('당일 입출금', f'{nsign}{net:,}원', _kpi_more_button(f'/api/cashflow?owner={_urlq(owner)}', '당일 입출금')) kpis += [ ('매입금액', f'{d["total_cost"]:,}원'), ('평가금액', f'{d["total_value"]:,}원'), ('평가손익', f'{sign}{profit:,}원 ({sign}{profit_rate:.2f}%)'), ('당일 평가손익', day_pl_html), ('예수금', f'{deposit_pct_html}{d["deposit"]:,}원{deposit_locked_html}'), ] if cashflow_row: kpis.append(cashflow_row) kpis.append(('보유종목/당일매매', f'{held_count}개 / {len(d["traded_today"])}개')) if not compact: realized_pl = d.get('realized_pl_total', 0) realized_fees = d.get('realized_fees_total', 0) if d.get('traded_today') and (realized_pl or realized_fees): rcls = _profit_class(realized_pl) rsign = '+' if realized_pl >= 0 else '' fee_html = f' · 수수료·세금 {realized_fees:,}원' if realized_fees else '' kpis.append(('당일 실현손익', f'{rsign}{realized_pl:,}원{fee_html}')) _rtk ={'평가금액': 'value', '평가손익': 'profit', '총자산': 'net', '당일 평가손익': 'daypl', '총수익': 'totret'} _row_list = [] for i, item in enumerate(kpis): k, v = item[0], item[1] lbl_suffix = item[2] if len(item) > 2 else '' # 라벨 뒤 버튼 등 (escape 대상 아님) attr = f' data-rt-kpi="{_rtk[k]}"' if k in _rtk else '' # 라벨 기준이라 조건부 행이 빠져도 선이 엉뚱한 데 붙지 않는다. 마지막 행엔 안 붙임. cls = ' class="kpi-sep"' if (k in _KPI_SEP_AFTER and i < len(kpis) - 1) else '' _row_list.append( f'{html.escape(k)}{lbl_suffix}{v}' ) rows = ''.join(_row_list) 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'' ) # data-rt-principal — setOwnerKpis가 순자산 틱마다 총수익을 재계산하는 데 쓴다(0=미집계). return f'''
    {html.escape(owner_label_text)}· {html.escape(labels_disp)}
    {trade_btn_html}
    {rows}
    ''' def _render_account_kpi(owner: str, label: str, owner_d: dict, balances: dict, journal_by_label: dict, owner_label_text: str, has_prev_snap: bool, label_disp: str | None = None) -> str: """단일 계좌 단위 compact KPI 카드. owner_d['rows'] 를 account==label 로 필터해 합산. 합산 뷰의 `_render_owner_kpi` 와 동일한 라벨·순서지만, 데이터 출처가 owner 합 대신 단일 라벨이라 당일 평가손익은 owner-level 전날 스냅샷(holdings는 owner 합)이라 정확한 분해가 불가 → `(day_change × qty)` 합으로 근사하고 `근사` 표기. 합계 뷰의 prev_net 기반 값은 owner 카드에서 확인. """ rows = [r for r in owner_d.get('rows', []) if r.get('account') == label] total_value = sum(r.get('eval_value', 0) for r in rows) total_cost = sum(r.get('buy_amount', 0) for r in rows) total_profit = sum(r.get('profit', 0) for r in rows) total_profit_rate = (total_profit / total_cost * 100) if total_cost else 0.0 # 종목 단위 unique count (한 라벨 내에선 중복 없지만 안전) held_count = len({r.get('code') for r in rows if r.get('qty', 0) > 0 and r.get('code')}) bal = balances.get(label) or {} deposit = bal.get('d2_entra', 0) or 0 total_net = total_value + deposit locked = bal.get('pending_buy_locked', 0) or 0 # 당일 평가손익 — 라벨별 prev_snap이 없어 근사 (day_change × qty). 실현손익·cash_flow는 포함 안 됨. if has_prev_snap and rows: approx_day_pl = sum(int(r.get('day_change', 0) or 0) * int(r.get('qty', 0) or 0) for r in rows) else: approx_day_pl = None # 라벨별 실현손익·당일매매 — journal_by_label[label] 에서 직접 추출 label_journal = journal_by_label.get(label, []) or [] sell_entries = [j for j in label_journal if (j.get('sell_qty') or 0) > 0] realized_pl = sum(int(j.get('pl_amt', 0) or 0) for j in sell_entries) realized_fees = sum(int(j.get('cmsn_tax', 0) or 0) for j in sell_entries) traded_today_codes = {j.get('code') for j in label_journal if ((j.get('buy_qty') or 0) + (j.get('sell_qty') or 0)) > 0 and j.get('code')} sign = '+' if total_profit >= 0 else '' pcls = _profit_class(total_profit) if approx_day_pl is None: day_pl_html = '전날 스냅샷 없음' else: dcls = _profit_class(approx_day_pl) dsign = '+' if approx_day_pl >= 0 else '' day_pl_html = f'{dsign}{approx_day_pl:,}원 · 근사' deposit_pct_html = '' if total_value: dep_pct = (deposit / total_value) * 100 deposit_pct_html = f'({dep_pct:.1f}%) ' deposit_locked_html = ( f' · 매수 묶임 {locked:,}원' if locked > 0 else '' ) # 라벨·순서는 _render_owner_kpi와 통일 ('총'=현금 포함 전체). kpis = [ ('총자산', f'{total_net:,}원'), ('매입금액', f'{total_cost:,}원'), ('평가금액', f'{total_value:,}원'), ('평가손익', f'{sign}{total_profit:,}원 ({sign}{total_profit_rate:.2f}%)'), ('당일 평가손익', day_pl_html), ('예수금', f'{deposit_pct_html}{deposit:,}원{deposit_locked_html}'), ('보유종목/당일매매', f'{held_count}개 / {len(traded_today_codes)}개'), ] if traded_today_codes and (realized_pl or realized_fees): rcls = _profit_class(realized_pl) rsign = '+' if realized_pl >= 0 else '' fee_html = f' · 수수료·세금 {realized_fees:,}원' if realized_fees else '' kpis.append(('당일 실현손익', f'{rsign}{realized_pl:,}원{fee_html}')) _rows = [] for i, (k, v) in enumerate(kpis): cls = ' class="kpi-sep"' if (k in _KPI_SEP_AFTER and i < len(kpis) - 1) else '' _rows.append(f'{html.escape(k)}{v}') rows_html = ''.join(_rows) sub_text = label_disp if label_disp is not None else label return f'''
    {html.escape(owner_label_text)}· {html.escape(sub_text)}
    {rows_html}
    ''' def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: str = 'none', al_price: int = 0, krx_closed: bool = False, nxt_closed: bool = False) -> 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, closed: bool = False) -> str: dim_cls = ' ohlc-dim' if dim else '' if not d: return f'-' extra = '' # 현재가 셀에만 WS 라이브 갱신 마커(data-rt-ohlc-cur) + 전일종가(data-prev) 부착. if key == 'price': # 현재가는 AL(통합) 가격을 양 컬럼 공통으로 표시(동일가). 등락률만 각 시장 전일종가 대비로 분리. price = al_price or d.get('price', 0) base = (d.get('prev_close', 0) or 0) if al_price else 0 if al_price and base: pct = (price - base) / base * 100 else: pct = d.get('change_pct', 0.0) or 0.0 extra = f' data-rt-ohlc-cur data-prev="{int(base) if base else 0}"' elif key == 'close_today': # 당일종가 — 그 시장이 마감된 뒤에만 그 시장의 종가(=ka10095 price)를 표시. 장중엔 미확정(·). if not closed: return f'·' price = d.get('price', 0) base = d.get('prev_close', 0) or 0 pct = ((price - base) / base * 100) if base else (d.get('change_pct', 0.0) or 0.0) elif key == 'prev_close': # 전 거래일 종가 — baseline 자체라 등락률 표시 없음. price = d.get('prev_close', 0) if not price: return f'-' return f'{price:,}원' elif key == 'volume': # 거래량 — 등락률 없이 주 수만 표시. vol = d.get('volume', 0) or 0 if not vol: return f'-' return f'{vol:,}주' else: price = d.get(key, 0) base = d.get('price', 0) - d.get('change', 0) pct = ((price - base) / base * 100) if base else 0.0 if not price: return f'-' cls = 'up' if pct > 0 else ('down' if pct < 0 else 'neutral') sgn = '+' if pct >= 0 else '' return ( f'{price:,}원' f'{sgn}{pct:.2f}%' ) krx_dim = active_market not in ('krx', 'none-active-krx') # 'krx' 활성 또는 양쪽 비활성-krx강조 시만 밝게 nxt_dim = active_market != 'nxt' # 'none' (양쪽 비활성, 휴장/심야) — 둘 다 dim if active_market == 'none': krx_dim = True nxt_dim = True elif active_market == 'krx': krx_dim = False nxt_dim = True elif active_market == 'nxt': krx_dim = True nxt_dim = False rows = [ ('전일종가', 'prev_close'), ('현재가', 'price'), ('시가', 'open'), ('고가', 'high'), ('저가', 'low'), ('당일종가', 'close_today'), ('거래량', 'volume'), ] krx_th_dim = ' ohlc-dim' if krx_dim else '' nxt_th_dim = ' ohlc-dim' if nxt_dim else '' parts = [ '
    ', '', f'KRX', f'NXT', ] for lbl, key in rows: parts.append(f'{lbl}') parts.append(_cell(krx if has_krx else None, key, krx_dim, krx_closed)) parts.append(_cell(nxt if has_nxt else None, key, nxt_dim, nxt_closed)) parts.append('
    ') return ''.join(parts) def _render_ohlc_mini(o: int, h: int, l: int, current: int, pred_krx: int, pred_nxt: int) -> str: """OHLC 4행 mini-table — 라벨/가격/전일(KRX)/전일(NXT) 4-col grid. 헤더 row 한 줄에 '전일(KRX)' '전일(NXT)' 컬럼명, 데이터 row는 4행(현재가/시가/고가/저가). pred_krx/pred_nxt 중 하나가 0이면 해당 컬럼은 모두 `-` placeholder — 컬럼 레이아웃 유지. KRX/NXT 기준이 같은 종목(NXT 미운영)에선 두 컬럼 값이 동일. """ if not (o > 0 and h > 0 and l > 0 and current > 0): return '' def _pct_cell(p: int, base: int) -> str: if not base or not p: return '-' d = p - base pct = d / base * 100 cls = 'up' if d > 0 else ('down' if d < 0 else 'neutral') sgn = '+' if d >= 0 else '' return f'{sgn}{pct:.2f}%' rows = [ ('현재가', current), ('시가', o), ('고가', h), ('저가', l), ] parts = [ '
    ', '', '', 'KRX', 'NXT', ] for lbl, p in rows: parts.append(f'{lbl}') parts.append(f'{p:,}원') parts.append(_pct_cell(p, pred_krx)) parts.append(_pct_cell(p, pred_nxt)) parts.append('
    ') return ''.join(parts) def _render_single_candle(o: int, h: int, l: int, c: int, width: int = 64, height: int = 110) -> str: """오늘 하루 단일 캔들 SVG. 시/고/저/현 4값으로 박스+wick. 빨강=상승, 파랑=하락 (한국 관례).""" if not (o > 0 and h > 0 and l > 0 and c > 0): return '' if h == l: return '' pad_top, pad_bot = 6, 6 plot_h = height - pad_top - pad_bot def y(price: int) -> float: return pad_top + (h - price) / (h - l) * plot_h cx = width / 2 body_top = y(max(o, c)) body_bot = y(min(o, c)) body_h = max(body_bot - body_top, 1.0) wick_top = y(h) wick_bot = y(l) color = '#ff4d5e' if c >= o else '#4d8cff' body_w = max(width * 0.55, 14) body_x = cx - body_w / 2 return ( f'' ) _EMPTY_KV_PAIR = '
    ' def _interleave_kv_pairs(left: list[str], right: list[str]) -> str: """좌·우 dt-dd 쌍 리스트를 4컬럼 row-major grid 순서로 인터리브.""" n = max(len(left), len(right)) out: list[str] = [] for i in range(n): out.append(left[i] if i < len(left) else _EMPTY_KV_PAIR) out.append(right[i] if i < len(right) else _EMPTY_KV_PAIR) return ''.join(out) def _pending_badges_html(r: dict) -> str: """미리보기 💰 배지 — 색만으로 매수(빨강)/매도(파랑) 구분. 수량·상세 정보는 자세히보기(`_pending_detail_html`)에서. """ buy = r.get('pending_buy_qty', 0) sell = r.get('pending_sell_qty', 0) parts: list[str] = [] if buy: parts.append( f'💰' ) if sell: parts.append( f'💰' ) return ''.join(parts) def _fmt_hhmmss(raw: str) -> str: """ka10075 'tm' 필드 'HHMMSS' → 'HH:MM:SS'. 이미 콜론이거나 비표준이면 그대로.""" s = (raw or '').strip() if not s or ':' in s: return s if len(s) == 6 and s.isdigit(): return f'{s[0:2]}:{s[2:4]}:{s[4:6]}' return s _TRAILING_LOOKUP_TTL = 5.0 _trailing_lookup_cache: dict = {'ts': 0.0, 'by_ord': {}} def _trailing_info_for(ord_no: str): """미체결 주문번호 → 트레일링 예약 요약 (없으면 None). `state/trailing_stops.json` 읽기라 키움 콜 0. 렌더 경로에서 주문마다 불리므로 5초 캐시. ⚠️ 예약의 ord_no 는 정정할 때마다 바뀌므로(kt10002 가 새 번호 발급) 항상 예약 파일의 현재 ord_no 와 매칭한다 — 등록 시점 번호로 매칭하면 첫 상향 후 연결이 끊긴다. """ if not ord_no: return None now = time.time() if now - _trailing_lookup_cache['ts'] > _TRAILING_LOOKUP_TTL: try: from orders import trailing as _trl # 예약 1건이 계단(레그) N개 = 미체결 주문 N건이라, 레그마다 (예약, 레그) 로 색인한다. by_ord = {} for r in _trl.list_active(): for s in r.get('steps') or []: by_ord[s['ord_no']] = (r, s) except Exception: by_ord = {} _trailing_lookup_cache['by_ord'] = by_ord _trailing_lookup_cache['ts'] = now hit = _trailing_lookup_cache['by_ord'].get(ord_no) if not hit: return None r, s = hit steps = r.get('steps') or [] return { 'id': r['id'], 'peak': r.get('peak', 0), 'min_sell_price': r.get('min_sell_price'), # 이 주문이 몇 단계인지 + 예약 전체가 몇 단인지 — 미체결 목록엔 레그가 따로 뜨므로 # "3단 중 2단계" 를 보여줘야 다른 행과의 관계가 읽힌다. 'step_n': s['n'], 'step_total': len(steps), 'trail_pct': s.get('pct'), 'cum': s.get('cum'), 'step_qty': s.get('qty', 0), 'modify_count': s.get('modify_count', 0), # 등록 시점 값 — 손절선·고점이 그 뒤로 얼마 올라왔는지 보여주는 데 쓴다. 'entry_cond_uv': s.get('entry_cond_uv', 0), 'entry_peak': r.get('entry_peak', 0), # 형제 계단 — 이 예약의 전체 그림을 한 행에서 보여준다. 'siblings': [{'n': x['n'], 'pct': x.get('pct'), 'cond_uv': x.get('cond_uv', 0), 'qty': x.get('qty', 0), 'cum': x.get('cum')} for x in steps], } def _stop_gap_html(stop_price: int, limit_price: int) -> str: """스톱 주문의 조건단가−지정가 갭 표기. 호가단위로 나눠 틱수로 보여준다. `orders/trailing.ORD_UV_GAP_TICKS` 값을 화면에 그대로 박지 않는 이유는 실제 주문에 걸린 값과 어긋날 수 있기 때문(정정 중 값 변화·키움앱 수동 개입). 실측값으로 역산한다. 틱으로 딱 나눠지지 않으면 원으로 폴백. """ gap = (stop_price or 0) - (limit_price or 0) if gap <= 0: return '' try: from orders.trailing import tick_size t = tick_size(stop_price) except Exception: t = 0 if t > 0 and gap % t == 0: return f' −{gap // t}틱' return f' −{gap:,}원' def _pending_detail_html(r: dict) -> str: """자세히보기용 미체결 ord 단위 상세. 매수/매도 분리 표시. `r['pending_orders']` 가 ord별 dict 리스트. 레이아웃: dt(라벨, 컬러) / dd 2~3줄(상단=수량@가격, 조건줄, 하단=계좌·시각·주문번호 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'] cur_price = r.get('price', 0) or 0 # 조건단가까지 남은 거리 계산용 def _row(o: dict, kind: str) -> str: qty = o.get('qty', 0) price = o.get('price', 0) otype = o.get('order_type') or '' stop_p = o.get('stop_price', 0) or 0 trl = o.get('trailing') 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 '미체결 매도' if kind == 'sell' and trl: label = '트레일링 매도' elif kind == 'sell' and stop_p: label = '스톱 매도' # 스톱 계열은 가격이 두 개다 — 조건단가(방아쇠)와 지정가(실제 주문가). # 첫 줄에 지정가만 '@ N원' 으로 박으면 그게 방아쇠처럼 오해되고 둘의 관계도 안 보인다. # 그래서 트레일링은 첫 줄에 수량만 남기고 두 가격을 미니표 맨 위 단독 항으로 올린다. if trl or stop_p: main = f'{qty:,}주 매도 예약' elif price: main = f'{qty:,}주 @ {price:,}원 {otype}' if otype else f'{qty:,}주 @ {price:,}원 지정가' else: main = f'{qty:,}주 @ {otype or "시장가"}' cond_line = '' if stop_p and not trl: # 순수 스톱지정가 — 조건단가 고정. 표 없이 한 줄이지만 순서는 트레일링과 같게 # (조건가 닿으면 → 지정가로 매도). 지정가가 먼저 오면 그게 방아쇠로 오해된다. tail = f' {price:,}원에 매도' if price else ' 매도' cond_line = (f'
    ' f'{stop_p:,}원 닿으면{tail}{_stop_gap_html(stop_p, price)} ' f'(고정)
    ') if trl: mini = [] mini.append(f'
    손절선
    {stop_p:,}원 도달 시
    ') if price: # 갭은 두 값의 차를 호가단위로 나눠 틱수로 표시 — ORD_UV_GAP_TICKS 를 그대로 # 박지 않는 이유는 실제 주문 값과 어긋날 수 있어서다(정정·수동 개입). # 틱으로 딱 안 나눠지면 원으로 폴백. gap_part = _stop_gap_html(stop_p, price) mini.append(f'
    매도 지정가
    {price:,}원{gap_part}
    ') # 고점에 '등록 시점' 라벨을 붙이지 않는다 — peak 는 감시가 갱신한 현재 고점이라 # 붙여 놓으면 지금 고점이 등록 시점 값인 것처럼 읽힌다 # (실측 entry_peak 4,860 vs peak 4,950). 등록 후 얼마나 올랐는지는 # 아래 '고점 상승' 행이 실제 값으로 보여준다. mini.append(f'
    고점
    {trl.get("peak", 0):,}원
    ') cum_v = trl.get('cum') or 0 cum_part = '전량' if cum_v >= 100 else f'누적 {cum_v:g}%' mini.append(f'
    계단
    {trl.get("step_n")}단계 / ' f'{trl.get("step_total")}단 · −{trl.get("trail_pct"):g}% ' f'({cum_part})
    ') if (trl.get('step_total') or 0) > 1: # 형제 계단 — 미체결 목록엔 레그가 각각 뜨므로, 이 예약 전체가 어떤 모양인지 # 한 행에서 보이게 한다. 지금 행에 해당하는 계단은 굵게. sib = ' · '.join( (f'{x["n"]}:{x["cond_uv"]:,}' if x['n'] == trl.get('step_n') else f'{x["n"]}:{x["cond_uv"]:,}') for x in (trl.get('siblings') or [])) mini.append(f'
    전체 계단
    {sib}
    ') if trl.get('min_sell_price'): mini.append(f'
    최저 매도가
    {trl["min_sell_price"]:,}원
    ') if cur_price > 0 and stop_p: # 손절선을 기준으로 현재가가 몇 % 위에 있는지 = 팔리기까지 남은 여유. # 라벨이 '손절선 대비'이므로 분모도 손절선이어야 라벨과 계산이 어긋나지 않는다. room = (cur_price - stop_p) / stop_p * 100 mini.append(f'
    손절선 대비
    {room:+.1f}% ' f'(현재 {cur_price:,}원)
    ') # 상향 횟수는 표시하지 않는다(관리자님 지시). mc 는 상승 행들의 표시 조건으로만 쓴다. mc = trl.get('modify_count', 0) entry_peak = trl.get('entry_peak') or 0 peak_now = trl.get('peak', 0) if entry_peak and peak_now > entry_peak: mini.append(f'
    고점 상승
    {entry_peak:,} → {peak_now:,}원 ' f'+{peak_now - entry_peak:,}
    ') entry_cond = trl.get('entry_cond_uv') or 0 if mc and entry_cond and stop_p > entry_cond: mini.append(f'
    손절선 상승
    {entry_cond:,} → {stop_p:,}원 ' f'+{stop_p - entry_cond:,}
    ') cond_line += f'
    {"".join(mini)}
    ' return ( f'
    {label}
    ' f'
    ' f'
    {main}
    ' f'{cond_line}' f'
    {" · ".join(meta_parts)}
    ' f'
    ' ) pairs: list[str] = [] for o in buys: pairs.append(_row(o, 'buy')) for o in sells: pairs.append(_row(o, 'sell')) return f'
    {"".join(pairs)}
    ' def _buy_rank_badge(buy_amount: int) -> str: """매입 총액 기반 등급 뱃지. 100/200/500/1000만원 경계로 1~5등급. 막대 stack(군대 계급 모티프).""" if not buy_amount or buy_amount <= 0: return '' M = 10_000 if buy_amount <= 100 * M: tier = 1 elif buy_amount <= 200 * M: tier = 2 elif buy_amount <= 500 * M: tier = 3 elif buy_amount <= 1000 * M: tier = 4 else: tier = 5 stripes = ''.join('' for _ in range(tier)) title = f'매입 {buy_amount:,}원 · 등급 {tier}' return f'{stripes}' def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False, key_suffix: str = '') -> str: stock = html.escape(r['stock']) # 종목명 길이 기반 글씨크기 — 길면 줄여서 한 줄에 맞춤(ellipsis 최소화, 렌더 시 확정이라 깜빡임 없음). _nlen = len(r.get('stock', '') or '') stock_cls = 'stock stock-xs' if _nlen >= 14 else ('stock stock-sm' if _nlen >= 10 else 'stock') code = html.escape(r.get('code', '') or '') accounts = html.escape('+'.join(r.get('accounts', []))) # 표시용 계좌명 — '가희_' 등 owner 접두 떼고 유형(일반/ISA)만, 중복 제거, '·' 구분. _acct_types: list[str] = [] for _lb in r.get('accounts', []): _t = (_lb or '').split('_')[-1] if _t and _t not in _acct_types: _acct_types.append(_t) acct_label = html.escape('·'.join(_acct_types)) # 소유자 — 계좌 라벨 '가희_' 접두로 판별. 팝업(탭 안 보임)에서 누구 보유인지 배지로 표시. _is_gahee = any((lb or '').startswith('가희') for lb in r.get('accounts', [])) owner_disp = '가희' if _is_gahee else '관리자' owner_cls = 'gahee' if _is_gahee else 'self' qty = r.get('qty', 0) avg = r.get('avg', 0) price = r.get('price', 0) profit = r.get('profit', 0) profit_rate = r.get('profit_rate', 0.0) eval_value = r.get('eval_value', 0) buy_amount = r.get('buy_amount', 0) weight = (eval_value / total_value * 100) if total_value else 0.0 pcls = _profit_class(profit) sign = '+' if profit >= 0 else '' # 오늘 매매 별표 — 매수/매도 구분. 둘 다 발생 시 두 개 표시. star_parts: list[str] = [] if r.get('tdy_buyq'): star_parts.append(f'') if r.get('tdy_sellq'): star_parts.append(f'') star = ''.join(star_parts) day_change = r.get('day_change', 0) day_change_pct = r.get('day_change_pct', 0.0) candle_summary = '' ohlc = r.get('ohlc') or {} if ohlc: mini = _render_single_candle(ohlc.get('o', 0), ohlc.get('h', 0), ohlc.get('l', 0), ohlc.get('c', 0), width=24, height=35) if mini: candle_summary = f'
    {mini}
    ' j = r.get('journal') or {} journal_lines: list[str] = [] summary_journal_html = '' if j.get('buy_qty'): journal_lines.append( f'
    매수
    {j["buy_avg"]:,}원 × {j["buy_qty"]:,}주 = {j["buy_amt"]:,}원
    ' ) 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'
    매도
    {j["sell_avg"]:,}원 × {j["sell_qty"]:,}주 = {j["sell_amt"]:,}원
    ' ) journal_lines.append( f'
    실현손익
    {ssign}{sell_pl:,}원 ' f'({ssign}{j["prft_rt"]:.2f}%) · 수수료·세금 {j["cmsn_tax"]:,}원
    ' ) # 부분매도 케이스 — 기본 카드 뷰에서도 실현손익이 보이도록 별도 줄로 표시. # 금액이 크면 비중과 같은 줄에서 폭이 터지므로 line2 를 한 줄 더 분리. summary_journal_html = ( f'
    매도 {j["sell_qty"]:,}주 실현 ' f'{ssign}{sell_pl:,}원
    ' ) pl_pairs: list[str] = [ f'
    평가손익
    {sign}{profit:,}원 ({sign}{profit_rate:.2f}%)
    ', ] # 수익률 마이너스(손실)일 때만 — 평단가 회복(본전)에 필요한 상승률. if profit_rate < 0 and price > 0 and avg > price: breakeven_pct = (avg - price) / price * 100 pl_pairs.append( f'
    평단가대비
    (+{breakeven_pct:.2f}%)
    ' ) 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'
    평가금액 변동
    {dvsign}{day_value:,}원
    ' ) pl_pairs.append( f'
    당일 등락
    {dsign}{day_change:,}원 ' f'({dsign}{day_change_pct:.2f}%)
    ' ) # 활성 시장 판정 — OHLC 미니테이블 컬럼 강조용(active_market). cur_price 가 어느 시장 가격과 일치하는지. nxt_p = (r.get('ohlc_nxt') or {}).get('price', 0) krx_p = (r.get('ohlc_krx') or {}).get('price', 0) mark = 'NXT' if (nxt_p and nxt_p == price and nxt_p != krx_p) else 'KRX' # 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음. (미리보기 행과 동일 규칙) # 정규장: KRX+NXT 모두 거래 → 초록점 / NXT 시간대: NXT 거래종목(ohlc_nxt 가격 살아있음)만 / 장외·휴장·주말: 점 없음 _phase = _market_phase_state()['phase'] if _phase == 'regular': _tradable = True elif _phase == 'nxt': _tradable = bool(nxt_p) else: _tradable = False mark_html = '' if _tradable else '' # 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') if ohlc_krx_for_render: ohlc_krx_for_render = dict(ohlc_krx_for_render) # KRX 전일종가 = ka10095(현재가 − 전일대비). kt00018 pred_close_krx는 장 마감 후 # 현재가와 같은 값으로 채워지는 결함이 있어 등락률이 0%로 깔린다 → ka10095 기준으로 산출. _krx_prev = (ohlc_krx_for_render.get('price', 0) or 0) - (ohlc_krx_for_render.get('change', 0) or 0) if _krx_prev <= 0: _krx_prev = r.get('pred_close_krx', 0) or 0 if _krx_prev > 0: ohlc_krx_for_render['prev_close'] = _krx_prev 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 # 당일종가: KRX는 정규장 마감(15:30) 후, NXT는 마감(20:00) 후에만 그 시장 종가 표시. _now_ohlc = datetime.now(KST) _hm_ohlc = _now_ohlc.hour * 60 + _now_ohlc.minute _trd_ohlc = _market_phase_state().get('phase') not in ('holiday', 'weekend') ohlc_mini_html = _render_ohlc_mini_dual( ohlc_krx_for_render, ohlc_nxt_for_render, active_market=mark.lower(), al_price=price, # 현재가 행은 AL(통합) 현재가를 양 컬럼 공통 — 등락률만 각 시장 전일종가 대비. krx_closed=_trd_ohlc and _hm_ohlc >= 15 * 60 + 30, nxt_closed=_trd_ohlc and _hm_ohlc >= 20 * 60, ) # 미리보기 등락은 rebase 후 day_change_pct 사용 — 어제 NXT 종가 baseline. # raw pred_close_krx 는 kt00018 결함으로 정규장 시작 전·마감 후 cur_price 와 같은 값이 들어와 # 등락률이 항상 0% 으로 깔리는 문제가 있어 사용 X. d_pct_val = r.get('day_change_pct') # show_day_change(시간대 게이트) 동안엔 항상 rt-daypct span 을 그린다 — 값이 0/None이어도. # span 이 없으면 WS paintDay(if(sp)) 가 실시간으로 못 채워, 한 번 0으로 렌더되면 등락률이 사라진 채 복구 안 됨. if show_day_change: _dp = d_pct_val if isinstance(d_pct_val, (int, float)) else 0.0 d_sign = '+' if _dp >= 0 else '' d_cls = 'day-pct up' if _dp > 0 else ('day-pct down' if _dp < 0 else 'day-pct neutral') price_html = f'{mark_html}{price:,}{d_sign}{_dp:.2f}%' else: price_html = f'{mark_html}{price:,}' chart_block = '' flow_block = '' if code: # 카드 펼침 이벤트에 클라이언트 JS 가 /api/chart_svg?code= 를 fetch 해 채움. # data-chart-code 속성으로 식별. panels swap 후 chartCache 복원도 같은 셀렉터로. chart_block = f'
    ' # 외국인·기관·개인 수급 (ka10059) — 팝업 열 때 client lazy fetch (flow_script) flow_block = f'
    ' left_pairs = [ f'
    종목코드
    {code}
    ', f'
    현재가
    {price:,}
    ', f'
    평단가
    {avg:,}원
    ', ] right_pairs = [ f'
    보유수량
    {qty:,}주
    ', f'
    매입금액
    {buy_amount:,}원
    ', f'
    평가금액
    {eval_value:,}원
    ', ] dl_inner = _interleave_kv_pairs(left_pairs, right_pairs) pl_pairs.extend(journal_lines) # 매수후 고점대비 — 보유 시작일 이후 일봉 고가 최댓값 대비 현재가 낙폭(%·평가액). # 매수/매도(journal_lines) 아래에 표시. 순수 캐시 읽기(_select_latest)만 — 렌더 중 키움 호출 방지. since = _holding_since_map().get(r.get('code') or '') peak_high = 0 if since and code: try: import daily_candles_cache as dcc for cd in dcc._select_latest(code, 250): d = str(cd.get('date') or '') h = cd.get('high') or 0 if d >= since and h > peak_high: peak_high = h except Exception: peak_high = 0 oh_today = r.get('ohlc') or {} if oh_today.get('h'): peak_high = max(peak_high, oh_today['h']) if price: peak_high = max(peak_high, price) if peak_high > 0 and price > 0: dd_pct = (price - peak_high) / peak_high * 100 dd_won = (price - peak_high) * qty ddcls = _profit_class(dd_won) pl_pairs.append('
    ') pl_pairs.append( f'
    매수후 고점
    {peak_high:,}원 ' f'({dd_pct:.2f}%)
    ' ) recover_pct = (peak_high - price) / price * 100 pl_pairs.append( f'
    고점대비
    {dd_won:,}원 ' f'(+{recover_pct:.2f}%)
    ' ) pl_pairs.append('
    ') 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 '') note_btn = _note_button_html(r.get('code') or '', r.get('stock') or '') trade_btn = ( f'
    ' f'{note_btn}' f'{_info_button_html(r.get("code") or "", r.get("stock") or "")}' f'' f'' f'
    ' ) if code else '' tag_chip = _tag_chip_html(r.get('code') or '', r.get('stock') or '', interactive=True) return f'''
    {_buy_rank_badge(buy_amount)}{stock}{acct_label}
    📦 {qty:,}{_pending_badges_html(r)}{_vol_surge_badge(code, (r.get('ohlc_krx') or {}).get('volume'))}{weight:.2f}%{star}
    {summary_journal_html}
    {price_html}
    평단 {avg:,}
    {sign}{profit:,}{sign}{profit_rate:.2f}%
    {candle_summary}
    {stock}{f'{code}' if code else ''}{owner_disp}{tag_add_btn}
    {f'
    {tag_chip}
    ' if tag_chip else ''} {f'
    현재가{price_html}
    ' if price else ''}
    비중 {weight:.2f}%{acct_label}{_market_label_html(code)}
    {dl_inner}
    {pl_dl_inner}
    {ohlc_mini_html} {_pending_detail_html(r)} {trade_btn} {flow_block} {chart_block}
    ''' 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'
    종목코드
    {code}
    '] 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'
    현재가
    {live_price:,}원 ' f'({lsign}{lpct:.2f}%)
    ' ) if j.get('sell_qty'): pairs.append( f'
    매도
    {j["sell_avg"]:,}원 × {j["sell_qty"]:,}주 = {j["sell_amt"]:,}원
    ' ) pairs.append( f'
    실현손익
    {sign}{pl:,}원 ' f'({sign}{rate:.2f}%) · 수수료·세금 {j["cmsn_tax"]:,}원
    ' ) # 어제 보유 → 오늘 풀매도 케이스: 어제 스냅샷의 평단을 '매수 평단'으로 노출 prev_avg = j.get('prev_avg', 0) if prev_avg > 0 and not j.get('buy_qty'): pairs.append( f'
    매수 평단
    {prev_avg:,}원 · 어제 잔고 기준
    ' ) if j.get('buy_qty'): pairs.append( f'
    매수
    {j["buy_avg"]:,}원 × {j["buy_qty"]:,}주 = {j["buy_amt"]:,}원
    ' ) chart_block = '' if code: chart_block = f'
    ' row_key = code or stock kind_label = '단타' if j.get('buy_qty') else '전량매도' return f'''
    {stock}당일정산{_pending_badges_html(r)}{accounts}
    {kind_label} · 매도 {j.get("sell_avg", 0):,}원 × {j.get("sell_qty", 0):,}주
    {f'
    현재 {live_price:,}원
    ' if live_price else ''}
    실현 {sign}{pl:,}원
    {sign}{rate:.2f}%
    {''.join(pairs)}
    {_pending_detail_html(r)} {chart_block}
    ''' def _render_pending_unheld_row(r: dict) -> str: """미보유 + 미체결 매수 주문 종목. 보유·당일정산 아닌 신규 매수 대기 행. summary는 종목명·미체결 합계, detail은 _pending_detail_html이 주문 단위로 출력.""" stock = html.escape(r.get('stock') or r.get('code') or '') code = html.escape(r.get('code') or '') buy_qty = r.get('pending_buy_qty', 0) price = r.get('price') # 미체결 매수 평균가 (가중평균) — 시장가(price=0)는 제외 orders = [o for o in (r.get('pending_orders') or []) if o.get('side') == 'BUY'] priced = [o for o in orders if o.get('price', 0) > 0] if priced: total_qty = sum(o['qty'] for o in priced) avg_buy = sum(o['qty'] * o['price'] for o in priced) // total_qty if total_qty else 0 else: avg_buy = 0 accounts = sorted({o.get('account', '') for o in orders if o.get('account')}) acc_text = html.escape('+'.join(accounts)) if isinstance(price, (int, float)) and price: # 미리보기 등락은 KRX 전일종가 대비 — ka10095의 change_pct 그대로. day_pct = r.get('day_change_pct') if isinstance(day_pct, (int, float)): d_cls = 'day-pct up' if day_pct > 0 else ('day-pct down' if day_pct < 0 else 'day-pct neutral') d_sign = '+' if day_pct >= 0 else '' price_html = f'{price:,}{d_sign}{day_pct:.2f}%' else: price_html = f'{price:,}' if avg_buy: diff = price - avg_buy dpct = (diff / avg_buy * 100) if avg_buy else 0.0 dcls = 'up' if diff > 0 else ('down' if diff < 0 else 'neutral') dsign = '+' if diff >= 0 else '' diff_html = f'{dsign}{diff:,.0f}{dsign}{dpct:.2f}%' else: diff_html = '' else: price_html = '-' diff_html = '' summary_line2 = f'매수 대기 {buy_qty:,}주' if avg_buy: summary_line2 += f' · 평균 주문가 {avg_buy:,}원' summary_line2 += '' chart_block = '' if code: chart_block = f'
    ' detail_pairs: list[str] = [f'
    종목코드
    {code}
    '] if isinstance(price, (int, float)) and price: detail_pairs.append(f'
    현재가
    {price:,}원
    ') if avg_buy: detail_pairs.append(f'
    주문 평균가
    {avg_buy:,}원
    ') detail_pairs.append(f'
    매수 대기
    {buy_qty:,}주
    ') row_key = code or stock return f'''
    {stock}매수등록{acc_text}
    {summary_line2}
    {price_html}
    {f'
    {diff_html}
    ' if diff_html else ''}
    {''.join(detail_pairs)}
    {_pending_detail_html(r)} {chart_block}
    ''' def _aggregate_by_unit(series: list[tuple[str, int]], unit: str) -> list[tuple[str, int]]: """단위(day/week/month/year)별로 각 구간의 마지막 영업일 점만 남김. 'day'는 그대로 반환. week=ISO week (월~일), month=YYYY-MM, year=YYYY.""" if unit == 'day' or not series: return series from datetime import date as _date groups: dict = {} # group key → (date_str, value) for d, v in series: try: dt = _date.fromisoformat(d) except Exception: continue if unit == 'week': iso = dt.isocalendar() key = (iso[0], iso[1]) elif unit == 'month': key = (dt.year, dt.month) elif unit == 'year': key = (dt.year,) else: return series prev = groups.get(key) if prev is None or d > prev[0]: groups[key] = (d, v) return sorted(groups.values(), key=lambda x: x[0]) def _load_cash_flow_by_date(owner: str) -> dict[str, dict]: """portfolio_daily_snapshot에서 owner별 일자별 입출금 dict. {date: {'cash_in', 'cash_out'}} — 둘 다 0인 날은 제외. legacy 스냅샷(cash_in 키 없음)은 자연스럽게 0 처리. stock_portfolio_report가 매일 20:10 스냅샷 저장 시 누적. 적재 시작 이전 과거 데이터는 비어있음. """ if not SNAPSHOT_FILE.exists(): return {} try: snap = json.loads(SNAPSHOT_FILE.read_text()) except Exception: return {} out: dict[str, dict] = {} for date_key, day_snap in snap.items(): owner_snap = (day_snap or {}).get(owner) or {} ci = int(owner_snap.get('cash_in', 0) or 0) co = int(owner_snap.get('cash_out', 0) or 0) if ci or co: out[date_key] = {'cash_in': ci, 'cash_out': co} return out def _aggregate_cash_flows_by_unit(daily_cf: dict[str, dict], aggregated_dates: list[str], unit: str) -> dict[str, dict]: """unit 단위 집계 시 cash flow 를 series 점에 매핑. aggregated_dates: 차트 series 의 날짜 리스트 (호출 시점 시리즈). 'day' 또는 daily_cf 빈 dict면 그대로 반환. week/month/year: - 그룹에 series 점이 1개(=대표 영업일)면 그 그룹의 daily cash flow 들을 sum 해서 대표 점에 매핑. - 그룹에 series 점이 여러 개(=_expand_progress_group이 daily 로 펼친 진행 중 그룹)면 각 daily 점에 daily_cf 그대로 매핑 — sum 매핑 시 모든 daily 점이 동일 cf 로 박히는 버그 방지. """ if unit == 'day' or not daily_cf: return dict(daily_cf) if unit == 'day' else {d: dict(cf) for d, cf in daily_cf.items() if d in set(aggregated_dates)} from datetime import date as _date def _key(d: str): dt = _date.fromisoformat(d) if unit == 'week': iso = dt.isocalendar() return (iso[0], iso[1]) if unit == 'month': return (dt.year, dt.month) if unit == 'year': return (dt.year,) return None # 그룹별 daily_cf sum (대표 매핑용) group_sum: dict = {} for d, cf in daily_cf.items(): try: k = _key(d) except Exception: continue if k is None: continue s = group_sum.setdefault(k, {'cash_in': 0, 'cash_out': 0}) s['cash_in'] += cf.get('cash_in', 0) s['cash_out'] += cf.get('cash_out', 0) # series 의 그룹별 점 개수 — 1점=대표, 2점 이상=daily 펼쳐짐. series_by_group: dict = {} for d in aggregated_dates: try: k = _key(d) except Exception: continue if k is None: continue series_by_group.setdefault(k, []).append(d) out: dict[str, dict] = {} for k, dates in series_by_group.items(): if len(dates) == 1: s = group_sum.get(k) if s and (s['cash_in'] or s['cash_out']): out[dates[0]] = {'cash_in': s['cash_in'], 'cash_out': s['cash_out']} else: for d in dates: cf = daily_cf.get(d) if cf and (cf.get('cash_in', 0) or cf.get('cash_out', 0)): out[d] = {'cash_in': cf.get('cash_in', 0), 'cash_out': cf.get('cash_out', 0)} return out def _available_chart_periods() -> dict: """스냅샷에 데이터가 있는 연도·연월 목록 (연도별/월별 드롭다운 채움용). 최신이 앞. 반환: {'years': ['2026','2025',...], 'months': {'2026': ['06','05',...], ...}}""" empty = {'years': [], 'months': {}} if not SNAPSHOT_FILE.exists(): return empty try: snap = json.loads(SNAPSHOT_FILE.read_text()) except Exception: return empty years: set = set() months: dict = {} for date_key in snap.keys(): if len(date_key) < 7 or date_key[4] != '-': continue y, m = date_key[:4], date_key[5:7] years.add(y) months.setdefault(y, set()).add(m) # 현재 연·월은 스냅샷이 아직 없어도(월초 EOD 적재 전) 드롭다운에 노출 — 지정기간 선택 가능하게. _now = datetime.now(KST) cy, cm = _now.strftime('%Y'), _now.strftime('%m') years.add(cy) months.setdefault(cy, set()).add(cm) return { 'years': sorted(years, reverse=True), 'months': {y: sorted(ms, reverse=True) for y, ms in months.items()}, } def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: str = 'day', with_prev_baseline: bool = False, period: str = ''): """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 period: # 절대 선택 — 'YYYY'(연도별·월별집계) 또는 'YYYY-MM'(월별·일별). caller가 unit을 맞춰 호출. range_start = (period + '-01-01') if len(period) == 4 else (period + '-01') filtered = [(d, v) for d, v in aggregated if d.startswith(period)] elif 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개만 앵커(기간 시작 기준값)로 들고 온다. 라이브 current_net 과 합쳐 2점 차트가 그려진다. # (2점 이상 당기면 6월 구간이 7월 차트에 그려져 오해 소지 — 앵커는 1점으로 제한.) 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, rt_owner: str | None = None, rt_base_net: int | None = None) -> str: """꺾은선 SVG (순자산 / 손익누적 공용). 외부 의존성 없음. 데이터 < 2점이면 안내문 반환. mode='net'(기본): series 는 raw 순자산 시계열. 입출금 마커·툴팁 행 표시. mode='pnl': series 는 _to_pnl_series 결과(첫 점=0 누적손익). 입출금 효과는 이미 제거, 마커·툴팁 행 모두 숨김. 헤더 라벨도 '손익누적' 으로 분기. current_net 은 mode에 맞춰 caller가 변환해서 넘긴다(net=순자산, pnl=누적손익). selected_unit (day/week/month/year)에 따라 X축 라벨 형식과 tooltip 손익 라벨이 분기된다. prev_baseline_point=(date, nw) 가 주어지면 차트 series 앞에 baseline 점 prepend → 첫 영업일 변동이 0 기점에서 분기되는 모양으로 시각화. 그 baseline ↔ 첫 영업일 구간은 점선. """ # pnl·period 모두 '입출금 제거 누적손익' 시계열을 입력으로 받는다(cf 마커·순자산 행 숨김 공통). # pnl=전부 누적 곡선, period=각 점이 그 기간(일/주/월/연) 자체 손익. is_pnl = mode in ('pnl', 'period') per_period = (mode == 'period') cash_flow_by_date = cash_flow_by_date or {} def _pp_key(ds: str): from datetime import date as _pp_date try: dt = _pp_date.fromisoformat(ds) except Exception: return None if selected_unit == 'week': iso = dt.isocalendar() return (iso[0], iso[1]) if selected_unit == 'month': return (dt.year, dt.month) if selected_unit == 'year': return (dt.year,) return ds # day — 각 날짜가 독립 기간 # baseline 점 prepend — PnL 모드면 baseline 값 = 0 (series 자체가 baseline 대비 누적이라). # net 모드면 prev nw 그대로. prev_baseline_nw = prev_baseline_point[1] if prev_baseline_point else None # day/total 계산용 baseline_prepended_pt: tuple[str, int] | None = None if prev_baseline_point and series: prev_dt, prev_nw = prev_baseline_point prev_v_for_chart = 0 if is_pnl else int(prev_nw) baseline_prepended_pt = (prev_dt, prev_v_for_chart) # 기간·단위·모드 토글은 자산정보 탭 상단의 공통 select 컨트롤로 이동 (_render_chart_controls). # 차트 wrapper엔 data-chart-days/unit/mode 박아 swap 후 상태 인식 가능. if selected_unit == 'month': empty_msg = '월별 차트는 2개월 이상의 데이터가 필요합니다.' elif selected_unit == 'week': empty_msg = '주별 차트는 2주 이상의 데이터가 필요합니다.' elif selected_unit == 'year': empty_msg = '연별 차트는 2년 이상의 데이터가 필요합니다.' else: _empty_metric = '기간손익' if per_period else ('손익누적' if is_pnl else '총자산') empty_msg = f'최근 {_empty_metric} 추이 데이터가 부족합니다.' def _empty(msg: str) -> str: return ( f'
    ' f'
    {msg}
    ' '
    ' ) if len(series) < 2 and current_net is None: return _empty(empty_msg) # stock.briefing 정규 적재 시각(20:10 KST) 이전이면 오늘 snapshot 행은 장중 추정치. # series 끝 행을 제거하고 live 점으로 교체 → 마지막 구간 점선 분기 보존. # 마감 후엔 confirmed로 간주, snapshot 오늘 행 유지 → 일반 실선. now = datetime.now(KST) today_kst = now.strftime('%Y-%m-%d') before_close = (now.hour, now.minute) < (20, 10) series = list(series) # 외부 인자 mutate 방지 + 로컬 컷 if before_close and series and series[-1][0] == today_kst: series = series[:-1] has_live = ( current_net is not None and (not series or series[-1][0] != today_kst) ) # 휴장일/주말은 시장 변동이 없어야 자연 — NXT 가격 보정으로 인한 가짜 점프(=오늘 라이브 점이 어제 스냅샷과 차이) 차단. if has_live and _market_phase_state().get('phase') in ('holiday', 'weekend'): has_live = False # per_period + 라이브 점이 있으면, 라이브와 같은 기간의 확정 점들은 중복이라 제거 (라이브가 그 기간 대표). # 예: 연별인데 2026 연 대표점(5/29) + 라이브(6/01) 둘 다 2026 → 5/29 버려 baseline→현재 2점으로 그림. if per_period and has_live and series: _live_key = _pp_key(today_kst) while series and _pp_key(series[-1][0]) == _live_key: series = series[:-1] # baseline 점이 있으면 series 앞에 prepend (시각화용). day/total tooltip 도 자연스레 baseline 기준이 됨. has_baseline = baseline_prepended_pt is not None full_series_with_baseline = ([baseline_prepended_pt] if has_baseline else []) + series full = full_series_with_baseline + ([(today_kst, int(current_net))] if has_live else []) if len(full) < 2: return _empty(empty_msg) values = [v for _, v in full] # per_period 라인 = '기간별 손익'(그 기간 자체 손익). values(누적)는 tooltip '누적 손익'·헤더 합계용 유지. # 각 점 = 그 점 누적값 − 직전 '다른 기간' 점의 누적값 (없으면 baseline 0). # 직전 '다른 기간' 기준이라, 같은 기간 점이 여러 개여도(올해+월별 진행 중 등) 그 기간 전체 손익이 잡힌다. # 단일 기간(예: 데이터가 2026 한 해뿐)이면 위에서 라이브 중복점을 정리해 baseline(0)→현재 2점으로 그린다. if per_period: _pp_keys = [_pp_key(d) for d, _ in full] plot_values = [] for _i in range(len(values)): _base = 0 for _j in range(_i - 1, -1, -1): if _pp_keys[_j] != _pp_keys[_i]: _base = values[_j] break plot_values.append(int(values[_i]) - _base) else: plot_values = values vmin, vmax = min(plot_values), max(plot_values) vrange = max(vmax - vmin, 1) pad = vrange * 0.10 y_lo = vmin - pad y_hi = vmax + pad span = max(y_hi - y_lo, 1) # viewBox 800x280, padding: 좌72 우16 상14 하34 (날짜라벨 + 상단 cf 라벨 영역). # 좌여백은 모바일 axis 폰트(22px)에서 '−350K'·'1.5M' 류 라벨이 안 잘리게 넓게 잡음. W, H = 800, 360 L, R, T, B = 72, 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)] # 눈금 간격(step)이 단위보다 작으면 floor 나눗셈이 라벨을 뭉개므로(예: 1M 1M 1M), # step 크기에 맞춰 소수 자릿수를 정해 인접 눈금이 항상 구별되게 한다. step = vrange / 3 dec_m = 0 if step >= 1_000_000 else (1 if step >= 100_000 else 2) dec_k = 0 if step >= 1000 else 1 grid_lines = [] for gv in grid_vals: gy = y_at(int(gv)) grid_lines.append(f'') label = f'{gv / 1_000_000:,.{dec_m}f}M' if abs(gv) >= 1_000_000 else f'{gv / 1000:,.{dec_k}f}K' grid_lines.append(f'{html.escape(label)}') # 단위 경계 세로선 — 일별=주 경계(ISO 주가 바뀌는 첫 점), 월별=연 경계(연도가 바뀌는 첫 점). # 그 외 단위는 미표시. per_period 모드는 period-sep(점마다 구분선)이 이미 있어 중복이라 생략. boundary_lines: list[str] = [] if not per_period and selected_unit in ('day', 'month'): from datetime import date as _bd_date def _bkey(ds: str): if selected_unit == 'day': try: iso = _bd_date.fromisoformat(ds).isocalendar() return (iso[0], iso[1]) # (ISO연, 주차) except Exception: return None return ds[:4] # month → 연도 _prev_bk = _bkey(full[0][0]) for _bi in range(1, n): _bk = _bkey(full[_bi][0]) if _bk is not None and _prev_bk is not None and _bk != _prev_bk: _bx = x_at(_bi) boundary_lines.append( f'' ) if _bk is not None: _prev_bk = _bk # 0원 기준선 — 손익 0 높이가 차트 범위 안일 때만 (어디부터 적자/흑자인지 표시). 순자산은 항상 양수라 자동 미표시. # 기준선(가로선)은 유지하고 '0' 텍스트 라벨만 제거 (관리자님 요청 — M/K 라벨과 겹쳐 보기 번잡). zero_line = '' if y_lo <= 0 <= y_hi: zy = y_at(0) zero_line = ( f'' ) label_idx = sorted({0, n // 2, n - 1}) # 점들이 모두 같은 그룹(month/year)이면 라벨 중복이라 한 단계 작은 단위로 fallback. # 라벨 형식은 unit 그대로 — year=YYYY, month=YYYY-MM, week/day=MM-DD. # 한 단위 안에 모든 점이 들어가는 케이스(이번달·올해 진행 중)도 unit 라벨 유지 → 중복 라벨 보일 수 있으나 사용자 의도(단위 명확 표시) 우선. def _x_label_for(date_iso: str) -> str: if selected_unit == 'year': return date_iso[:4] if selected_unit == 'month': return date_iso[:7] return date_iso[5:] x_labels = [] for i in label_idx: date_str = _x_label_for(full[i][0]) if has_live and i == n - 1: date_str += '*' # 장중 잠정 표시 x_labels.append( f'{html.escape(date_str)}' ) last_x, last_y = pts[-1] # per_period면 마지막 점 = 마지막 기간 손익(=마지막 dot 높이와 일치). 아니면 누적 마지막값. last_v = plot_values[-1] if per_period else values[-1] if is_pnl: last_sign = '+' if last_v >= 0 else '−' last_label_v = f'{last_sign}{abs(last_v):,}원' else: last_label_v = f'{last_v:,}원' if has_live: last_label_v += ' (장중)' range_label = f'{_x_label_for(full[0][0])} ~ {_x_label_for(full[-1][0])}' # per_period는 baseline 제외 실제 표시 기간 수(라이브 포함). 라이브 중복점 제거로 series가 비어도 정확. if per_period: title_n = len({k for k in _pp_keys[base_offset:] if k is not None}) else: title_n = len(series) _unit_word = {'week': '주', 'month': '월', 'year': '연'}.get(selected_unit, '영업일') _title_metric = '손익' if per_period else ('손익누적' if is_pnl else '총자산') title = f'최근 {title_n}{_unit_word} {_title_metric}' + (' · 오늘 잠정' if has_live else '') # 영역 채우기는 종가 구간만 (실선 영역). 점선 구간은 area 미적용. area_d = '' if len(solid_pts) >= 2: area_d = ( f'M {solid_pts[0][0]:.2f},{T + ih:.2f} ' + 'L ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in solid_pts) + f' L {solid_pts[-1][0]:.2f},{T + ih:.2f} Z' ) solid_d = '' if len(solid_pts) >= 2: solid_d = 'M ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in solid_pts) dashed_d = '' if len(dashed_pts) >= 2: dashed_d = 'M ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in dashed_pts) baseline_dashed_d = '' if len(baseline_dashed_pts) >= 2: baseline_dashed_d = 'M ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in baseline_dashed_pts) last_dot_class = 'last-dot live' if has_live else 'last-dot' # 호버/터치 인터랙션용 메타. 각 포인트의 viewBox 좌표·날짜·순자산·그날 손익·누적 손익·입출금. base_v = values[0] prev_v: int | None = None pts_meta: list[dict] = [] today_kst_str = today_kst # 위에서 산출됨 for i, (d_str, v_i) in enumerate(full): x_i, y_i = pts[i] cf = cash_flow_by_date.get(d_str) # 오늘 라이브 포인트는 별도 today_cash_flow 우선 — snapshot 적재 전이라도 KPI와 일치하게. if d_str == today_kst_str and today_cash_flow and (today_cash_flow.get('cash_in', 0) or today_cash_flow.get('cash_out', 0)): cf = {'cash_in': int(today_cash_flow.get('cash_in', 0) or 0), 'cash_out': int(today_cash_flow.get('cash_out', 0) or 0)} # PnL 모드: v 자체가 이미 baseline 대비 누적손익 → total=v_i (재차감 X). # day 는 직전 점 대비 변동분이고, 첫 점은 baseline 대비라 v_i 자체가 첫날 손익 의미 (0 아님). # net 모드: v 가 raw 순자산. prev_baseline_nw 가 있으면 첫 점 day/total 도 그 대비 (휴장일 baseline 등). if is_pnl: # per_period면 '그 기간 손익' 행을 plot_values 와 일치시킨다(라이브점=이번 달 전체 손익). # 아니면(day) 직전 점 대비 변동분. total 은 항상 누적값. day_val = int(plot_values[i]) if per_period else (int(v_i - prev_v) if prev_v is not None else int(v_i)) total_val = int(v_i) else: if prev_v is not None: day_val = int(v_i - prev_v) elif prev_baseline_nw is not None: day_val = int(v_i - prev_baseline_nw) else: day_val = 0 total_val = int(v_i - prev_baseline_nw) if prev_baseline_nw is not None else int(v_i - base_v) meta = { 'd': d_str, 'v': int(v_i), 'day': day_val, 'total': total_val, 'x': round(x_i, 2), 'y': round(y_i, 2), } if cf and (cf.get('cash_in', 0) or cf.get('cash_out', 0)): ci_i = int(cf.get('cash_in', 0) or 0) co_i = int(cf.get('cash_out', 0) or 0) meta['cf_in'] = ci_i meta['cf_out'] = co_i meta['cf_net'] = ci_i - co_i pts_meta.append(meta) prev_v = v_i pts_attr = html.escape(json.dumps(pts_meta, ensure_ascii=False), quote=True) # 입출금 수직선 — 해당 포인트 x좌표에 차트 영역을 가로지르는 dashed 선. # 색상: net > 0 입금 우세 = #0a7 / net < 0 출금 우세 = #d33 / 0 (= 입+출 동일) = #888. # 작은 캡션 라벨(±N) 을 차트 상단에 살짝 띄움 — 텍스트로 명확히 표시. cf_markers: list[str] = [] for meta in pts_meta: if 'cf_net' not in meta: continue cf_x = meta['x'] cf_net = meta['cf_net'] cf_color = '#0a7' if cf_net > 0 else ('#d33' if cf_net < 0 else '#888') cf_sign = '+' if cf_net >= 0 else '−' cf_abs = abs(cf_net) cf_label = f'{cf_sign}{cf_abs // 1_000_000}M' if cf_abs >= 1_000_000 else ( f'{cf_sign}{cf_abs // 1000}K' if cf_abs >= 1000 else f'{cf_sign}{cf_abs}' ) cf_markers.append( f'' ) cf_markers.append( f'{html.escape(cf_label)}' ) # per_period: 각 기간 점마다 세로 구분선 + 작은 마커 — 기간별 손익이 비슷해 라인이 평평해도 기간 경계를 읽을 수 있게. # (누적/순자산은 연속 곡선이라 구분선 불필요 — period 모드만) period_seps = [] point_dots = [] for _i, (px, py) in enumerate(pts): if per_period: # 구분선은 기간손익 전용. 점 색 = 그 기간 손익 부호 (이익=빨강 / 손실=파랑 / 0=회색). period_seps.append( f'' ) _pv = plot_values[_i] _dot_color = '#ff4d5e' if _pv > 0 else ('#4a8cf0' if _pv < 0 else '#6b7280') else: # 순자산·손익누적은 연속 곡선 — 점만 라인색으로 (부호색은 순자산이 항상 양수라 무의미). _dot_color = line_color point_dots.append(f'') svg_parts = [ f'', '' f'' f'' f'' '' '', ''.join(grid_lines), ''.join(boundary_lines), zero_line, ''.join(period_seps), ] if area_d: svg_parts.append(f'') # 입출금 수직선은 line/area 위, dot 아래 — 추세선 위에 살짝 비치고 사용자 데이터 점은 가리지 않음. if cf_markers: svg_parts.append(''.join(cf_markers)) if baseline_dashed_d: svg_parts.append(f'') if solid_d: svg_parts.append(f'') if dashed_d: svg_parts.append(f'') if point_dots: svg_parts.append(''.join(point_dots)) if has_live: svg_parts.append( f'' ) svg_parts.append( f'' ) # 고/저점 dot — 장중가(마지막 점)가 극값일 때도 표시. last_dot 위에 색 점이 덮인다. peak_i = max(range(len(plot_values)), key=lambda i: plot_values[i]) low_i = min(range(len(plot_values)), key=lambda i: plot_values[i]) _is_ath = n >= 2 and plot_values[-1] > max(plot_values[:-1]) # 고점 — 단, 마지막 점이 신고가(ATH)면 신고가 링/라벨이 대신하므로 '고' 생략. if not (peak_i == n - 1 and _is_ath): peak_x, peak_y = pts[peak_i] svg_parts.append( f'' ) svg_parts.append( f'' ) # 저점 — 고점과 다른 점일 때 (장중가가 저점이어도 표시). if low_i != peak_i: low_x, low_y = pts[low_i] svg_parts.append( f'' ) svg_parts.append( f'' ) # 신고가 마커 — 오늘(마지막) 그려진 값이 이전 모든 포인트를 strict 하게 초과하면 last dot 위에 금색 ring + 라벨. # per_period면 '역대 최고 기간 손익', 아니면 누적/순자산 신고가. if _is_ath: svg_parts.append( f'' ) _ath_label_y = max(last_y - 14, T + 12) svg_parts.append( f'신고가' ) svg_parts.append(''.join(x_labels)) svg_parts.append( '' f'' '' '' ) svg_parts.append('') _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 = ( '' ) head_html = ( '
    ' f'{html.escape(title)}' f'{html.escape(delta_label)}' '
    ' ) foot_html = ( '
    ' f'{html.escape(range_label)}' '' '' '' f'{html.escape(last_label_v)}' '' '
    ' ) return ( f'
    ' + head_html + ''.join(svg_parts) + tip_html + foot_html + '
    ' ) 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' · 매수 묶임 {lk:,}원' if lk > 0 else '' cash_lines.append( f'
  • [{html.escape(lb)}] 예수금 {b["d2_entra"]:,}원{lock_html}
  • ' ) if cash_lines: parts.append( '' f'
      {"".join(cash_lines)}
    ' ) 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'' ) 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'' ) # 보유 종목의 매수등록 카드도 보유종목 카드를 그대로 쓴다 (2026-07-30 관리자님 지시, # 매도등록과 동일한 이유·방식). pending_buy_held 원소가 consolidated 행 객체 자체다. # ⚠️ pending_unheld(미보유 + 매수 미체결)는 보유 수량·평단·손익이 없어 종목카드로 # 대체할 수 없다 — 전용 카드 유지. for r in sorted(pending_buy_held, key=lambda x: -x.get('pending_buy_qty', 0)): parts.append(_render_holding_row(r, d['total_value'], show_day_change, key_suffix=':pending-buy')) 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'' ) # 매도등록 카드는 보유종목 카드를 그대로 쓴다 (2026-07-30 관리자님 지시). # pending_sell_held 원소가 consolidated(보유종목) 행 객체 자체라 필요한 필드가 다 있고, # 전용 카드는 summary 값을 detail 에서 되풀이하면서 손익·버튼·태그가 없어 축소판이었다. # 같은 종목이 자산정보 탭 보유종목과 겹치므로 key_suffix 로 row_key 를 분리한다 # (바로 아래 ':held' 와 같은 방식 — mutex/open 복원 충돌 방지). for r in sorted(pending_sell_held, key=lambda x: -x.get('pending_sell_qty', 0)): parts.append(_render_holding_row(r, d['total_value'], show_day_change, key_suffix=':pending-sell')) # 당일매매 발생한 종목도 보유 종목 섹션에 그대로 표시 — 양쪽 다 보이게 함. # 같은 종목이 양쪽에 들어가니 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( '
    ' '' '' '
    ' ) if not held and not phantoms: parts.append('
    보유 종목 데이터가 없습니다.
    ') # rt-scope: 이 owner의 KPI 카드 + 보유행을 한 컨테이너로 묶음. 실시간 JS가 consolidated pane 행을 # 합산해 같은 scope 안의 data-rt-kpi(총평가금액·총평가손익·순자산·당일평가손익) td를 갱신. # deposit은 순자산 계산용. # 당일평가손익 = total_net − prev_net − net_cash_flow 에서 실시간 변동분은 평가액(total_value)뿐 → # 당일평가손익 = 실시간평가액 + C, C = day_pl_total − total_value (세션 중 상수). # prev_net은 % 계산용. day_pl_total None(전날 스냅 없음·휴장)이면 속성 미부착 → JS가 스킵. deposit = d.get('deposit', 0) or 0 _daypl = d.get('day_pl_total') _attrs = f'data-rt-owner="{html.escape(owner, quote=True)}" data-rt-deposit="{deposit}"' if _daypl is not None: _attrs += f' data-rt-daypl-c="{_daypl - d.get("total_value", 0)}" data-rt-prevnet="{d.get("prev_net", 0) or 0}"' return f'
    ' + '\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) + 44px); -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } /* 하단 고정 시장 지수. viewport 최하단에 박힘. iOS 홈 인디케이터 영역만 safe-area 패딩으로 양보. */ /* 지수 2개 = 한 묶음. 바를 탭할 때마다 다음 묶음으로 순환(마지막 → 처음). 자동 스크롤·자동 회전 없음 — 흘러가는 마퀴는 2026-07-31 관리자님 지시로 제거됐다. 되살리지 말 것. */ .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 고정: 라벨줄이 한글(닛케이225·금·은·동)이라 normal이면 ~1.45em로 늘어나 바 높이가 어긋난다. */ line-height: 1.2; font-variant-numeric: tabular-nums; color: #e6e6e6; overflow: hidden; white-space: nowrap; /* 세로 3줄: 지수명 / 가격+등락 / 묶음 점. 점을 옆이 아니라 아래 줄 가운데로 둔 배치(2026-07-31 관리자님 지시). */ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; /* iOS Safari는 비대화형 div에 cursor:pointer가 없으면 click을 합성하지 않는다 — 탭 전환의 전제조건. 지우지 말 것. */ cursor: pointer; user-select: none; -webkit-user-select: none; } /* 세로 flex라 flex-grow를 주면 높이가 늘어난다 → 0 0 auto + width로 가로만 채운다. max-width: PC·아이패드에서 두 칸이 화면 절반씩 벌어져 텅 비어 보이는 걸 막는 상한. */ .ticker-page { flex: 0 0 auto; width: 100%; max-width: 520px; display: flex; align-items: center; min-height: 26px; } /* 탭 전환 때만 페이드. 20초 주기 자동 갱신(animate=false)은 값만 조용히 교체. */ .ticker-page.fade { animation: ticker-fade 140ms ease-out; } @keyframes ticker-fade { from { opacity: 0.35; } to { opacity: 1; } } .market-ticker .idx { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 1px; } /* max-width+overflow: 칸보다 긴 내용은 오른쪽만 잘린다. .idx에 걸면 가운데정렬 탓에 양끝이 깎여 앞글자까지 사라진다. */ .market-ticker .idx-label { max-width: 100%; overflow: hidden; text-overflow: ellipsis; color: #8b8f9a; font-weight: 500; font-size: 9.5px; } .market-ticker .idx-val { max-width: 100%; min-width: 0; overflow: hidden; display: flex; align-items: baseline; gap: 4px; } /* 좁은 화면에서 칸을 넘칠 때: 가격은 절대 안 줄이고(flex:0 0 auto) 등락만 말줄임. 둘 다 기본 shrink로 두면 flex가 양쪽을 눌러 앞글자까지 깎이고 두 칸 폭도 어긋난다(320px 실측). */ .market-ticker .idx-price { flex: 0 0 auto; color: #f0f0f0; font-weight: 500; font-size: 10.5px; } .market-ticker .idx-delta { min-width: 0; overflow: hidden; text-overflow: ellipsis; 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; } /* 묶음 점 — 3번째 줄 가운데(부모 align-items:center가 가로 정렬). 지수 칸과 가로 폭을 나눠쓰지 않으므로 묶음 수가 줄어도 칸 폭은 영향 없다(옆에 두던 시절 필요했던 min-width 고정폭은 그래서 제거됨). */ .ticker-dots { flex: 0 0 auto; display: flex; align-items: center; gap: 3px; } .ticker-dots i { width: 4px; height: 4px; border-radius: 50%; background: #333844; } .ticker-dots i.on { background: #c9ccd3; } /* min-height/line-height를 표시 상태 content 높이(26 + gap 3 + 점 4 = 33)와 맞춰 로딩↔표시 전환 때 바가 안 튀게. */ .market-ticker .idx-empty { flex: 0 0 auto; width: 100%; text-align: center; min-height: 33px; line-height: 33px; color: #5a5f6c; font-size: 10px; } /* ── 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; } } .top-actions { display: flex; align-items: center; gap: 8px; flex: none; } /* 자동 갱신 상태 배지 — 클릭 불가 표시 전용. 장중=초록+남은초, 장마감=흐림. */ .auto-badge { flex: none; display: inline-flex; align-items: center; justify-content: center; user-select: none; } /* 상태 점 — 장중 초록 / 장마감 어두운 회색. */ .auto-badge::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: #3a3f4a; transition: background 0.2s, box-shadow 0.2s; } .auto-badge.live::before { background: #22c55e; box-shadow: 0 0 5px rgba(34,197,94,0.6); } .top-trade-btn { flex: none; display: inline-flex; align-items: center; justify-content: center; height: 36px; padding: 0 12px; border-radius: 8px; background: #2a1418; color: #ff8a95; border: 1px solid rgba(255,138,149,0.45); font-size: 13px; font-weight: 600; line-height: 1; font-family: inherit; cursor: pointer; user-select: none; white-space: nowrap; transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s; } .top-trade-btn:hover { background: rgba(255,138,149,0.16); border-color: #ff8a95; color: #fff; } .top-trade-btn:active { transform: scale(0.95); } .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; } /* 보유행 1줄: 뱃지+당일마크+종목명+계좌 한 줄 고정(nowrap). 이름 길면 .stock 이 ellipsis 로 줄어듦. */ .line1-held { flex-wrap: nowrap; } .acct-mini { font-size: 8.5px; color: #7a7f8c; flex: none; } /* 종목명 오른쪽 계좌명 작게 */ .stock { font-size: 14px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .stock.stock-sm { font-size: 12px; } /* 이름 10자+ */ .stock.stock-xs { font-size: 10.5px; } /* 이름 14자+ */ .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; } /* 3줄 고정: 1줄=뱃지+종목명(line1), 2줄=보유+비중(line2), 3줄=태그+계좌종류(line3). 각 줄 flex 단일행(미래 wrap 안 함). */ .line3 { display: flex; align-items: center; gap: 6px; font-size: 11px; min-width: 0; } .wt-chip { white-space: nowrap; } /* '비중 xx.xx%' 가 좁은 폭에서 내부 줄바뀜 되는 것 방지 (삼성전자 등 보유수량 자릿수 큰 경우) */ .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셀). */ /* 팝업 폭 대응 — 라벨(전일종가/현재가/…) 열을 고정폭으로 좁히고 KRX/NXT 열을 넓혀 값 폰트(13/12) 유지. 단가 큰 종목(예: 하이닉스)도 nowrap 으로 한 줄 유지. */ .ohlc-mini.ohlc-dual { grid-template-columns: 48px 1fr 1fr; column-gap: 6px; } .ohlc-mini.ohlc-dual .ohlc-rl { font-size: 11px; white-space: nowrap; } .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: 4px; min-width: 0; } .ohlc-mini.ohlc-dual .ohlc-cell .ohlc-price { font-size: 13px; text-align: right; white-space: nowrap; } .ohlc-mini.ohlc-dual .ohlc-cell .ohlc-pct { font-size: 12px; min-width: auto; white-space: nowrap; } .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; } /* 현재가 왼쪽 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음. (미리보기·보유 행 공용) */ .px-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 5px; vertical-align: 1px; } .px-dot.tradable { background: #2ecc71; box-shadow: 0 0 4px rgba(46,204,113,0.6); } .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: 0 4px; border-radius: 3px; font-size: 8.5px; font-weight: 500; line-height: 1.4; 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; } /* 펼친 detail의 태그는 크게 (미리보기·관심행 칩은 작게 유지). */ .detail-name { font-size: 15px; font-weight: 700; color: #f0f0f0; margin: 0 0 8px; display: flex; align-items: baseline; gap: 8px; } .detail-name .detail-code { font-size: 11px; font-weight: 500; color: #8b8f9a; font-variant-numeric: tabular-nums; } .detail-market { align-self: center; font-size: 9px; font-weight: 500; color: #6b7280; } /* 현재가 아래 메타 라인 — 비중 · 계좌명 · 시장 (작은 회색, · 구분) */ .detail .detail-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: -4px 0 10px; font-size: 11px; color: #8b8f9a; } .detail .detail-meta .dm-item { font-weight: 500; } .detail .detail-meta > * + *::before { content: "·"; margin-right: 8px; color: #4b5563; } .detail .detail-meta .detail-market { font-size: 11px; } .detail-name .detail-owner { font-size: 11px; font-weight: 600; border-radius: 4px; padding: 1px 6px; align-self: center; } .detail-name .detail-owner.owner-self { color: #6aa9ff; background: rgba(106,169,240,0.12); border: 1px solid rgba(106,169,240,0.4); } .detail-name .detail-owner.owner-gahee { color: #e879f9; background: rgba(232,121,249,0.12); border: 1px solid rgba(232,121,249,0.4); } .detail-name .btn-add-tag { align-self: center; } .detail-tag { margin: 0 0 8px; } .detail-tag .tag-chip { font-size: 11px; padding: 2px 8px; border-radius: 4px; } /* 심플정보 — 팝업 상단에 접힌 행(summary)의 종목 기본정보(현재가·등락) 재노출. 팝업 전환으로 summary가 백드롭 뒤로 가려져 보이던 정보. */ .detail .detail-simple { display: flex; align-items: baseline; gap: 8px; margin: 0 0 10px; padding: 8px 10px; background: #131820; border: 1px solid #1f2330; border-radius: 8px; } .detail .detail-simple .ds-lbl { font-size: 11px; font-weight: 700; color: #8b8f9a; } .detail .detail-simple .ds-val { font-size: 16px; font-weight: 700; color: #f0f0f0; font-variant-numeric: tabular-nums; display: inline-flex; align-items: baseline; gap: 6px; } .detail .detail-simple .ds-val .day-pct { font-size: 13px; font-weight: 600; } /* 팝업 상단 sticky 헤더 (JS가 detail-name + detail-simple 을 묶어 최상단 고정) — 종목명·소유자·현재가 항상 노출 */ .detail .detail-header { position: sticky; top: 0; z-index: 4; margin: 0 -16px 8px; padding: 12px 16px 8px; background: #0d1018; border-bottom: 1px solid #1f2330; } .detail .detail-header .detail-name { margin: 0; } .detail .detail-header .detail-simple { margin: 6px 0 0; } .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); font-size: 10px; padding: 2px 7px; } .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; } /* 스톱·트레일링 조건단가 줄 — 좁은 폭에서 줄바꿈되게(가로 스크롤 방지) */ dl.pending-detail .pending-line-cond { margin-top: 2px; color: #8b8f9a; overflow-wrap: anywhere; } dl.pending-detail .pending-line-cond.trailing { color: #d9a84a; } /* 트레일링 근거 미니표 — dd 안에 중첩되는 dl. 라벨 좌측 / 값 우측정렬로 자리수를 맞춘다. 두 열 모두 minmax(0,..) 라 좁은 폭에서도 안 넘친다. */ dl.pending-detail dl.trail-mini { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 1px 8px; margin: 4px 0 0; padding: 5px 7px; font-size: 10.5px; border: 1px solid rgba(217,168,74,0.22); border-radius: 6px; background: rgba(217,168,74,0.05); } dl.pending-detail dl.trail-mini dt { color: #8b8f9a; font-weight: 400; white-space: nowrap; } dl.pending-detail dl.trail-mini dd { margin: 0; text-align: right; color: #a8adb8; line-height: 1.45; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } dl.pending-detail dl.trail-mini dd b { color: #d9a84a; font-weight: 600; } /* ⚠️ 보유종목 행(.row.mode-held)의 `.detail dl` 은 4열 grid(80px 1fr 80px 1fr)이고 명시도가 (0,3,1) 로 위 `dl.pending-detail dl.trail-mini` (0,2,2) 보다 높다. 되돌리지 않으면 미니표가 4열로 펼쳐져 고정 80px 두 개 때문에 폭을 넘겨 잘린다. pending-detail 도 같은 이유로 바로 위에서 2열로 되돌려 놓았다. 이 규칙은 (0,4,1) 이라 640px 미디어쿼리 안의 4열 규칙(0,3,1)까지 함께 이긴다 — 미디어쿼리용 사본은 필요 없다. */ .row.mode-held .detail dl.trail-mini { grid-template-columns: minmax(0, auto) minmax(0, 1fr); } /* 트레일링 미리보기 카드 — .order-inputs 실폭이 좁아(모바일 ~200px) 가로로 벌어지는 요소를 두면 가로 스크롤이 생긴다. 전부 세로 쌓기 + minmax(0,..) + anywhere 로 묶는다. */ .trail-pv { border: 1px solid #1f2330; border-radius: 8px; padding: 8px 9px; background: #0f131c; display: flex; flex-direction: column; gap: 7px; min-width: 0; overflow-wrap: anywhere; } .trail-pv-hero { display: flex; flex-direction: column; gap: 1px; min-width: 0; } .trail-pv-hero .cap { font-size: 10px; color: #7c8290; letter-spacing: .02em; } .trail-pv-hero .val { font-size: 17px; font-weight: 700; color: #cfd3dc; font-variant-numeric: tabular-nums; line-height: 1.15; } .trail-pv-hero .sub { font-size: 10px; color: #7c8290; } .trail-pv.bad { border-color: rgba(224,85,85,0.45); background: rgba(224,85,85,0.07); } .trail-pv.bad .val { color: #e05555; font-size: 13px; } .trail-pv-kv { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 2px 8px; font-size: 11px; border-top: 1px solid #1a1e28; padding-top: 6px; } .trail-pv-kv dt { color: #7c8290; } .trail-pv-kv dd { margin: 0; text-align: right; color: #a8adb8; font-variant-numeric: tabular-nums; } .trail-pv-kv dd b { color: #cfd3dc; font-weight: 600; } .trail-pv-note { font-size: 10px; color: #6f7480; line-height: 1.45; border-top: 1px solid #1a1e28; padding-top: 6px; } /* 계단 편집기 — .order-inputs 실폭이 모바일 ~200px 라 열 하나하나가 아깝다. 행마다 "하락/누적" 글자를 반복하는 대신 헤더 한 줄로 빼고, 화살표도 지웠다. 그 결과 입력칸이 38px → 78px 로 넓어진다(고정폭 58 → 44, 라벨·단위 21px 회수). 스피너를 없애는 것도 폭 확보의 일부 — 데스크톱에서 화살표가 숫자를 가리던 문제도 같이 해결. */ .trail-steps { display: flex; flex-direction: column; gap: 3px; margin-top: 5px; min-width: 0; } .trail-step { display: grid; align-items: center; gap: 4px; min-width: 0; grid-template-columns: 12px minmax(0,1fr) minmax(0,1fr) 16px; font-size: 10px; color: #7c8290; } .trail-step-head { color: #5a5f6c; font-size: 9px; letter-spacing: .02em; } .trail-step-head span { text-align: center; } .trail-step .tsn { color: #6f7480; font-variant-numeric: tabular-nums; text-align: center; } .trail-step .tsw { display: flex; align-items: center; gap: 2px; min-width: 0; } .trail-step input { width: 100%; min-width: 0; padding: 4px 5px; font-size: 12px; background: #0f131c; border: 1px solid #262b38; border-radius: 5px; color: #cfd3dc; font-variant-numeric: tabular-nums; text-align: right; } .trail-step input::-webkit-outer-spin-button, .trail-step input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .trail-step .tsu { color: #6f7480; flex: 0 0 auto; font-size: 9px; } .trail-step .tsx { background: none; border: 0; color: #5a5f6c; cursor: pointer; font-size: 12px; padding: 0; line-height: 1; } .trail-step .tsx:hover { color: #e05555; } .trail-add-btn { width: 100%; margin-top: 5px; padding: 5px 6px; font-size: 10px; cursor: pointer; background: #171b26; border: 1px dashed #2c3140; border-radius: 6px; color: #8b8f9a; } .trail-add-btn:hover { background: #1d2230; color: #cfd3dc; border-color: #3a4152; } /* 미리보기 계단 목록 — 한 줄에 넷을 욱여넣으면(계단·가격·주수·누적) 200px 를 넘겨 접힌다. 1행 "N단계 −P% … 가격", 2행 "주수 · 누적" 으로 나눠 가격을 우측 정렬로 세운다. */ .trail-pv-steps { display: flex; flex-direction: column; gap: 5px; font-size: 10px; border-top: 1px solid #1a1e28; padding-top: 6px; min-width: 0; } .trail-pv-step { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 1px 6px; align-items: baseline; font-variant-numeric: tabular-nums; } .trail-pv-step .sp { color: #7c8290; } .trail-pv-step .sv { color: #cfd3dc; font-weight: 600; text-align: right; } .trail-pv-step .sq { grid-column: 1 / -1; color: #6f7480; font-size: 9px; } .trail-pv-step.tied .sv { color: #d9a84a; } .trail-pv-badge { display: inline-block; font-size: 9px; padding: 1px 5px; border-radius: 999px; background: #2a2313; color: #d9a84a; margin-left: 4px; vertical-align: middle; } .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; } /* 종목 상세 팝업(모달) —
    open 시 .detail 을 화면 중앙 오버레이 카드로 띄움. PC·모바일 공통 중앙 카드(작게). 닫기(✕)는 팝업 내부 sticky 바 — 크기·스크롤과 무관하게 우상단 고정. 차트/크로스헤어/mutex/상태보존 JS는 그대로. */ #detail-backdrop { position: fixed; inset: 0; z-index: 900; background: rgba(0,0,0,0.62); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); } #detail-backdrop[hidden] { display: none; } body.detail-open { overflow: hidden; } details.row[open] > .detail { position: fixed; z-index: 950; top: 50%; left: 50%; transform: translate(-50%, -50%); width: min(520px, calc(100% - 32px)); max-height: calc(100dvh - 120px); overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; border: 2px solid #ffffff; border-radius: 14px; box-shadow: 0 18px 50px rgba(0,0,0,0.5); padding-top: 0; padding-bottom: 0; /* 상단 헤더·하단 액션 바가 가장자리에 딱 붙도록 위·아래 여백 제거 */ } /* 팝업 하단 sticky 액션 바 (JS가 열린 .detail 의 .actions 를 맨 끝으로 옮기고 이 클래스 부여) — 메모·상세·거래내역·거래 + 닫기, 항상 노출 */ .detail .actions.detail-actions-footer { position: sticky; bottom: 0; z-index: 3; margin: 8px -16px 0; padding: 8px 16px 10px; gap: 6px; background: #0d1018; border-top: 1px solid #1f2330; } /* 닫기 버튼 — 하단 바 마지막 항목. 전체 너비 한 줄로 (자기 행 차지) */ .detail .actions .btn-detail-close { flex: 1 1 100%; padding-top: 11px; padding-bottom: 11px; color: #e6e9ef; border-color: #3a4154; } .detail .actions .btn-detail-close:hover { background: #22262f; border-color: #6aa9ff; color: #fff; } /* 종목 변경 드롭다운 — 하단 바 버튼 위(자기 행 차지) */ .detail .actions .detail-stock-switch { flex: 1 1 100%; min-width: 0; padding: 7px 10px; background: #0a0d12; color: #e6e9ef; border: 1px solid #2a3142; border-radius: 6px; font-family: inherit; font-size: 14px; } .detail .actions .detail-stock-switch:focus { outline: none; border-color: #6aa9ff; } /* 외국인·기관·개인 수급 블록 (팝업 lazy fetch) */ .detail .flow-box { margin-top: 14px; } .flow-title { font-size: 12px; font-weight: 700; color: #cfd3db; margin-bottom: 6px; } .flow-title .flow-unit { font-size: 10px; font-weight: 500; color: #7a8493; } .flow-title.flow-err { color: #8b8f9a; font-weight: 500; } .flow-tbl { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; } .flow-tbl th { font-size: 10px; font-weight: 500; color: #8b8f9a; text-align: right; padding: 3px 4px; border-bottom: 1px solid #1f2330; } .flow-tbl th:first-child { text-align: left; } .flow-tbl td { font-size: 12px; padding: 4px; text-align: right; border-bottom: 1px solid #14171f; } .flow-tbl td.flow-d { text-align: left; color: #8b8f9a; } .flow-tbl td.up { color: #ff4d5e; } .flow-tbl td.down { color: #4a8cf0; } .flow-tbl td.neu { color: #8b8f9a; } .flow-more { margin-top: 8px; width: 100%; padding: 7px; background: #14171f; color: #c9ccd3; border: 1px solid #2a3142; border-radius: 6px; font-family: inherit; font-size: 12px; cursor: pointer; } .flow-more:hover { background: #1c2030; border-color: #6aa9ff; } .flow-modal { position: fixed; inset: 0; z-index: 1100; display: flex; align-items: center; justify-content: center; padding: 40px 16px; } .flow-modal-ov { position: absolute; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); } .flow-modal-box { position: relative; width: 100%; max-width: 460px; max-height: 80vh; overflow-y: auto; background: #0d1018; border: 1px solid #2f3543; border-radius: 12px; padding: 14px 16px; box-shadow: 0 18px 50px rgba(0,0,0,0.55); } .flow-modal-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 13px; font-weight: 700; color: #f0f0f0; margin: -14px -16px 10px; padding: 12px 16px 8px; position: sticky; top: -14px; background: #0d1018; border-bottom: 1px solid #1f2330; } .flow-modal-x { flex: none; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; padding: 0; background: #1a1d26; color: #e6e9ef; border: 1px solid #2f3543; border-radius: 50%; font-size: 14px; line-height: 1; cursor: pointer; font-family: inherit; } .flow-modal-x:hover { background: #22262f; color: #fff; } .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; min-width: 0; overflow-wrap: anywhere; } /* 워치리스트·관심종목 카드 본문 — 라벨/값 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-cap { margin-top: 6px; padding: 6px 4px 0; font-size: 11px; line-height: 1.5; color: #7a8493; } .detail .block.chart-svg .chart-cap b { color: #9aa3b0; font-weight: 600; } .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-hlines { margin-top: 8px; } .detail .block.chart-svg .chart-hlines .hl-row { display: flex; align-items: center; gap: 6px; } .detail .block.chart-svg .chart-hlines .hl-list { display: flex; flex-wrap: wrap; gap: 5px; flex: 1; min-width: 0; } .detail .block.chart-svg .chart-hlines .hl-empty { font-size: 11px; color: #7a8493; } .detail .block.chart-svg .chart-hlines .hl-chip { display: inline-flex; align-items: center; padding: 2px 9px; background: rgba(255,255,255,0.04); border: 1px solid; border-radius: 12px; font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; } .detail .block.chart-svg .chart-hlines .hl-add-open { flex: none; width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; padding: 0; background: rgba(232,121,249,0.14); color: #e879f9; border: 1px solid rgba(232,121,249,0.5); border-radius: 8px; font-size: 20px; font-weight: 700; line-height: 1; cursor: pointer; font-family: inherit; } .detail .block.chart-svg .chart-hlines .hl-add-open:hover { background: rgba(232,121,249,0.24); } /* 수평선 추가·관리 모달 (position:fixed 로 상세 팝업 위에 뜸) */ .hl-modal { position: fixed; inset: 0; z-index: 1200; display: flex; align-items: center; justify-content: center; padding: 40px 16px; } .hl-modal.hidden { display: none; } .hl-modal .hl-modal-ov { position: absolute; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); } .hl-modal .hl-modal-box { position: relative; width: 100%; max-width: 320px; max-height: 70vh; overflow-y: auto; background: #0d1018; border: 1px solid #2f3543; border-radius: 12px; padding: 14px 16px; box-shadow: 0 18px 50px rgba(0,0,0,0.55); } .hl-modal .hl-modal-head { display: flex; align-items: center; justify-content: space-between; font-size: 13px; font-weight: 700; color: #f0f0f0; margin-bottom: 12px; } .hl-modal .hl-modal-x { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; padding: 0; background: #1a1d26; color: #e6e9ef; border: 1px solid #2f3543; border-radius: 50%; font-size: 16px; line-height: 1; cursor: pointer; font-family: inherit; } .hl-modal .hl-modal-x:hover { background: #22262f; } .hl-modal .chart-hlines-add { display: flex; gap: 6px; margin-bottom: 12px; } .hl-modal .chart-hlines-add input { flex: 1; min-width: 0; padding: 8px 10px; background: #0a0d12; color: #e6e9ef; border: 1px solid #2a2f3a; border-radius: 6px; font-size: 16px; font-family: inherit; } .hl-modal .chart-hlines-add input:focus { outline: none; border-color: #e879f9; } .hl-modal .chart-hlines-add .hl-add-btn { padding: 8px 14px; background: rgba(232,121,249,0.14); color: #e879f9; border: 1px solid rgba(232,121,249,0.5); border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 700; font-family: inherit; white-space: nowrap; } .hl-modal .chart-hlines-add .hl-add-btn:hover { background: rgba(232,121,249,0.24); } .hl-modal .hl-manage-list { display: flex; flex-direction: column; gap: 6px; } .hl-modal .hl-mrow { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: #131820; border: 1px solid #1f2330; border-radius: 6px; } .hl-modal .hl-swatch { flex: none; width: 14px; height: 14px; border-radius: 3px; } .hl-modal .hl-mprice { flex: 1; font-size: 13px; font-weight: 600; color: #e6e9ef; font-variant-numeric: tabular-nums; } .hl-modal .hl-mdel { padding: 4px 10px; background: transparent; color: #ff8a96; border: 1px solid rgba(255,77,94,0.32); border-radius: 6px; font-size: 12px; cursor: pointer; font-family: inherit; } .hl-modal .hl-mdel:hover { background: rgba(255,77,94,0.1); } .hl-modal .hl-empty { font-size: 12px; color: #7a8493; text-align: center; padding: 8px; } .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; } .badge.vol-hot { padding: 1px 5px; border-radius: 8px; font-size: 9px; font-weight: 700; font-variant-numeric: tabular-nums; } .badge.vol-hot.vol-warm { color: #f5a623; background: rgba(245,166,35,0.14); border: 1px solid rgba(245,166,35,0.45); } .badge.vol-hot.vol-red { color: #ef4444; background: rgba(239,68,68,0.16); border: 1px solid rgba(239,68,68,0.5); } .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; } /* 관심종목 그룹 선택 — 풀다운 + 오른쪽 '+' 메뉴. .sub-tab 과 별개 클래스. */ .igrp-select-wrap { display: flex; align-items: center; gap: 8px; margin: 8px 14px 6px; } .igrp-select { flex: 1; min-width: 0; appearance: none; -webkit-appearance: none; background: #14171f url("data:image/svg+xml;utf8,") no-repeat right 12px center; color: #e6e6e6; border: 1px solid #1f2330; border-radius: 8px; padding: 9px 34px 9px 12px; font-size: 14px; font-weight: 500; font-family: inherit; cursor: pointer; } .igrp-select:focus { outline: none; border-color: #ff4d5e; } .igrp-flat-label { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; color: #d6d8dd; font-size: 14px; font-weight: 600; padding: 9px 2px; } .igrp-menu-wrap { position: relative; flex: 0 0 auto; } .igrp-add-btn { width: 38px; height: 38px; display: inline-flex; align-items: center; justify-content: center; background: #14171f; color: #e6e6e6; border: 1px solid #2f3543; border-radius: 8px; font-size: 22px; line-height: 1; cursor: pointer; font-family: inherit; } .igrp-add-btn:hover { border-color: #ff4d5e; color: #ff4d5e; } .igrp-add-btn:active { transform: translateY(1px); } .igrp-menu { position: absolute; right: 0; top: calc(100% + 4px); z-index: 60; min-width: 132px; background: #1a1d26; border: 1px solid #2f3543; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); padding: 4px; } .igrp-menu[hidden] { display: none; } .igrp-menu-item { display: block; width: 100%; text-align: left; background: transparent; border: 0; color: #e6e6e6; padding: 10px 12px; font-size: 14px; font-family: inherit; cursor: pointer; border-radius: 6px; white-space: nowrap; } .igrp-menu-item:hover { background: #232733; color: #ff6b78; } .igrp-panel[hidden] { display: none; } /* 그룹 배정 모달 체크박스 */ .group-checks { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 10px; } .group-check { display: inline-flex; align-items: center; gap: 6px; background: #14171f; border: 1px solid #1f2330; border-radius: 8px; padding: 8px 12px; font-size: 14px; color: #e6e6e6; cursor: pointer; } .group-check input { accent-color: #ff4d5e; width: 16px; height: 16px; } /* 그룹 관리 모달 — 그룹당 2줄 카드(이름·수 / 액션) */ .group-manage-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; max-height: 46vh; overflow-y: auto; } .group-manage-row { display: flex; flex-direction: column; gap: 8px; background: #14171f; border: 1px solid #1f2330; border-radius: 8px; padding: 10px; } .gmr-top { display: flex; align-items: center; gap: 10px; } .gmr-actions { display: flex; align-items: center; gap: 6px; } .gmr-spacer { flex: 1; } .group-name-input { flex: 1; min-width: 0; background: #0e1017; border: 1px solid #1f2330; border-radius: 6px; padding: 8px 10px; color: #e6e6e6; font-size: 15px; font-family: inherit; } .group-name-input:focus { outline: none; border-color: #ff4d5e; } .group-count { flex: 0 0 auto; white-space: nowrap; } .btn-mini { background: #0e1017; color: #cfd3dc; border: 1px solid #2f3543; border-radius: 6px; padding: 6px 10px; font-size: 12px; cursor: pointer; font-family: inherit; } .btn-mini:hover { border-color: #ff4d5e; color: #ff4d5e; } .btn-mini:disabled { opacity: 0.35; cursor: default; } .btn-mini.btn-delete { color: #ff8a95; } .group-new-row { display: flex; align-items: center; gap: 8px; } .group-new-row input { flex: 1; min-width: 0; background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 9px 10px; color: #e6e6e6; font-size: 16px; font-family: inherit; } .group-new-row input:focus { outline: none; border-color: #ff4d5e; } .group-new-row .btn-add { flex: 0 0 auto; } .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; } .add-form-grid label:has(textarea) { grid-column: 1 / -1; } .add-form-grid textarea { background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 9px 10px; color: #e6e6e6; font-size: 16px; font-family: inherit; resize: vertical; min-height: 96px; } .add-form-grid textarea: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); } .actions .btn-note { color: #c9b27d; border: 1px solid rgba(201,178,125,0.35); background: rgba(201,178,125,0.06); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; } .actions .btn-note:hover { background: rgba(201,178,125,0.14); border-color: #c9b27d; } .actions .btn-note:active { transform: translateY(1px); } .actions .btn-note.has-note { color: #f0c95a; border-color: rgba(240,201,90,0.5); background: rgba(240,201,90,0.12); } .note-head-left { display: flex; align-items: center; gap: 8px; min-width: 0; } .note-del-wrap { position: relative; display: inline-flex; } .note-confirm { position: absolute; top: calc(100% + 6px); left: 0; display: flex; align-items: center; gap: 6px; background: #1b1f2a; border: 1px solid #3a2030; border-radius: 8px; padding: 6px 8px; box-shadow: 0 6px 18px rgba(0,0,0,0.4); white-space: nowrap; z-index: 5; } .note-confirm.hidden { display: none; } .note-confirm-msg { font-size: 11px; color: #e6c0c0; } .note-confirm-yes, .note-confirm-no { font-size: 11px; font-weight: 600; padding: 3px 9px; border-radius: 6px; cursor: pointer; font-family: inherit; border: 1px solid transparent; } .note-confirm-yes { background: #ff4d5e; color: #fff; } .note-confirm-yes:hover { background: #e23a4b; } .note-confirm-no { background: transparent; color: #aab2c4; border-color: rgba(160,170,190,0.35); } .note-confirm-no:hover { background: rgba(160,170,190,0.10); } .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-summary { background: #11161f; border: 1px solid #232a36; border-radius: 8px; padding: 10px 12px; margin: 0 0 12px; } .info-jd + .info-jd { margin-top: 8px; } .info-jd > b { display: block; font-size: 12px; margin-bottom: 3px; } .info-jd-s > b { color: #8ab4f8; } .info-jd-s-b { font-size: 12.5px; color: #d6d8dd; line-height: 1.65; } .info-jd-g > b { color: #34d399; } .info-jd-c > b { color: #fbbf24; } .info-summary ul { margin: 0; padding-left: 17px; } .info-summary li { font-size: 12.5px; color: #cfd5df; line-height: 1.65; margin: 1px 0; } .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: 10px; } .btn-trades-all { background: #14171f; color: #c9ccd3; border: 1px solid rgba(201,204,211,0.28); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; flex: 0 0 auto; } .btn-trades-all:hover { background: rgba(201,204,211,0.06); border-color: #6aa9ff; color: #6aa9ff; } .btn-trades-all:active { transform: translateY(1px); } .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 - 126px); } @supports not (height: 100dvh) { section.tab-content { min-height: calc(100vh - 126px); } } /* ── 계좌현황 패널 ── */ .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-title 안 인라인(제목과 한 줄). 하단 여백은 owner-title-row가 갖는다. */ .owner-sub { margin-left: 6px; font-size: 11px; font-weight: 400; letter-spacing: 0; color: #5a5f6c; white-space: nowrap; } .owner-card table.kpi { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; } .owner-card table.kpi th { text-align: left; font-weight: 400; color: #8b8f9a; 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; } /* KPI 행 구분선 — 표시한 행 아래에 선 (_KPI_SEP_AFTER: 투자원금·보유종목/당일매매) */ .owner-card table.kpi tr.kpi-sep > th, .owner-card table.kpi tr.kpi-sep > td { border-bottom: 1px solid #1f2330; padding-bottom: 9px; } .owner-card table.kpi tr.kpi-sep + tr > th, .owner-card table.kpi tr.kpi-sep + tr > td { padding-top: 9px; } /* KPI 라벨 옆 작은 [!] — kpi-modal 팝업 (투자원금·당일 입출금) */ .owner-card table.kpi th .kpi-more-btn { display: inline-block; margin-left: 5px; width: 15px; height: 15px; padding: 0; border-radius: 50%; font-size: 10px; font-weight: 700; line-height: 13px; text-align: center; color: #5a5f6c; background: transparent; border: 1px solid rgba(120,124,135,0.35); cursor: pointer; font-family: inherit; vertical-align: middle; } .owner-card table.kpi th .kpi-more-btn:hover { color: #d6b34a; border-color: rgba(214,179,74,0.55); } .owner-card table.kpi th .kpi-more-btn:active { transform: translateY(1px); } /* ── 투자원금 팝업 ── */ .principal-body { font-variant-numeric: tabular-nums; } .pm-sum { padding: 10px 12px; background: #11141c; border: 1px solid #1f2330; border-radius: 8px; } .pm-srow { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; line-height: 2; } .pm-srow span { color: #8b8f9a; } .pm-srow b { color: #d7dae1; font-weight: 500; } .pm-srow.strong { font-size: 14px; padding-bottom: 4px; margin-bottom: 4px; border-bottom: 1px solid #1f2330; } .pm-srow.strong span { color: #c9ccd3; } .pm-srow.strong b { color: #f0f0f0; font-weight: 600; } .pm-shead { margin-top: 6px; padding-top: 6px; border-top: 1px solid #1f2330; font-size: 10px; color: #6b7080; } .pm-warn { margin-top: 8px; font-size: 11px; line-height: 1.5; color: #d6b34a; } .pm-note { margin: 10px 2px 0; font-size: 11px; line-height: 1.55; color: #6b7080; } .pm-note-in { margin: 6px 0 0; } /* 요약 박스 안쪽 주석 */ .pm-empty { margin-top: 12px; } .pm-listhead { margin: 14px 2px 8px; font-size: 12px; font-weight: 600; color: #c9ccd3; } /* 배당·이자 등 포함 토글 — 체크박스 + 형제 선택자로 JS 없이 필터 */ .pm-all-cb { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; } .pm-all-lb { display: inline-block; margin: 0 2px 8px; padding: 3px 9px; border-radius: 5px; font-size: 11px; color: #5a5f6c; border: 1px dashed rgba(120,124,135,0.30); cursor: pointer; } .pm-all-lb:hover { color: #d6b34a; border-color: rgba(214,179,74,0.45); } .pm-all-cb:checked ~ .pm-all-lb { color: #d6b34a; border-color: rgba(214,179,74,0.45); border-style: solid; } .pm-all-cb:not(:checked) ~ .pm-list .pm-row.other { display: none; } .pm-list { display: flex; flex-direction: column; max-height: 46dvh; overflow-y: auto; } .pm-row { display: grid; grid-template-columns: 74px 1fr auto auto; gap: 8px; align-items: baseline; padding: 6px 2px; border-bottom: 1px solid #14171f; font-size: 11px; } .pm-row.other { opacity: 0.55; } .pm-row .pm-d { color: #6b7080; } .pm-row .pm-rk { color: #c9ccd3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pm-row .pm-ac { color: #5a5f6c; font-size: 10px; } .pm-row .pm-amt { font-weight: 600; white-space: nowrap; } .pm-row .pm-amt.in { color: #ff4d5e; } .pm-row .pm-amt.out { color: #4a8cf0; } .cash-list { list-style: none; margin: 4px 14px 12px; padding: 8px 12px; background: #0d1018; border: 1px solid #14171f; border-radius: 8px; font-size: 12px; color: #c9ccd3; font-variant-numeric: tabular-nums; } .cash-list li { padding: 2px 0; } .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 .grid-v { stroke: #353b4a; stroke-width: 1; stroke-dasharray: 3 3; opacity: 0.8; } .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 .low-dot { stroke: #0b0d12; stroke-width: 1.5; } .net-chart-svg .hl-label { paint-order: stroke; stroke: #0b0d12; stroke-width: 3; } .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: 104px; } table.market-adr td.adr-val b { font-size: 14px; } .adr-line { line-height: 1.45; white-space: nowrap; } .adr-when { font-size: 10px; color: #8b8f9a; margin-right: 3px; } table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; } .adr-tag { display: inline-block; padding: 1px 6px; border-radius: 999px; 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; } /* ADR 기본정보 표 — 시장별 20일/오늘 ADR + 종목수. market-table 기반, 시장 컬럼만 좌측정렬. */ table.adr-info-table { margin: 4px 0 2px; } table.adr-info-table td { padding: 5px 0 5px 8px; vertical-align: middle; } table.adr-info-table th { padding: 0 0 3px 8px; } table.adr-info-table td.adr-mk, table.adr-info-table th:first-child { text-align: left; padding-left: 0; color: #c9ccd3; } table.adr-info-table td.adr-mk { font-weight: 600; } table.adr-info-table td b { font-size: 14px; } table.adr-info-table .adr-tag { margin-left: 0; margin-right: 5px; } table.adr-info-table td.adr-breakdown { font-size: 11px; color: #8b8f9a; white-space: nowrap; } .adr-cnt-sub { font-size: 9px; color: #6b7280; font-weight: 400; } @media (max-width: 600px) { table.adr-info-table td.adr-breakdown { font-size: 10px; } table.adr-info-table td b { font-size: 13px; } } @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-info { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 14px; margin-top: 7px; font-size: 12px; color: #d6d8dd; font-variant-numeric: tabular-nums; } .adr-trend-info .tip-d { font-weight: 600; color: #f0f0f0; } .adr-trend-info .tip-line { display: inline-flex; align-items: baseline; gap: 5px; } .adr-trend-info .tip-line[hidden] { display: none; } .adr-trend-info .tip-mk { font-size: 11px; color: #8b8f9a; } .adr-trend-info .tip-kospi .tip-mk { color: #fbbf24; } .adr-trend-info .tip-kosdaq .tip-mk { color: #34d399; } .adr-trend-info .tip-v { font-weight: 700; } .adr-trend-info .tip-v.up { color: #ff8a96; } .adr-trend-info .tip-v.down { color: #8db4f4; } .adr-trend-info .tip-v.flat { color: #d6d8dd; } .adr-trend-info .tip-v.live::after { content: " *"; color: #fbbf24; font-weight: 600; } .adr-trend-empty { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 8px 0; } .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 블록 — 카드 기본(접힘) 상태에 항상 노출. 시장별 한 줄(값·판정·종목수). */ .adr-today-block { display: flex; flex-direction: column; gap: 4px; margin: 6px 0 2px; font-variant-numeric: tabular-nums; } .adr-today-block .adr-today-when { font-size: 11px; color: #8b8f9a; font-weight: 500; } .adr-today-item { display: flex; align-items: baseline; flex-wrap: wrap; gap: 3px 8px; font-size: 13px; color: #d6d8dd; } .adr-today-mk { font-size: 11px; color: #8b8f9a; min-width: 38px; } .adr-today-item b { font-weight: 700; } .adr-today-item b.up { color: #ff8a96; } .adr-today-item b.down { color: #8db4f4; } .adr-today-item b.neutral { color: #d6d8dd; } .adr-today-breakdown { font-size: 11px; color: #8b8f9a; margin-left: auto; } /* 추세 그래프 토글 버튼 — .adr-range-btn 톤 재사용. */ .adr-graph-toggle { appearance: none; -webkit-appearance: none; background: #11141c; color: #c9ccd3; border: 1px solid #2a2e36; border-radius: 8px; padding: 6px 12px; margin: 6px 0 2px; font-size: 11px; font-weight: 500; font-family: inherit; cursor: pointer; } .adr-graph-toggle:hover, .adr-graph-toggle:active { background: #1a1d26; color: #f0f0f0; } .adr-graph-toggle[aria-expanded="true"] { background: rgba(251,191,36,0.12); color: #fbbf24; border-color: rgba(251,191,36,0.4); } .adr-graph-toggle:focus-visible { outline: 1px solid #4a4f5a; outline-offset: 1px; } .adr-graph-wrap[hidden] { display: none; } .adr-live-badge { display: inline-block; margin-left: 6px; font-size: 9px; font-weight: 600; letter-spacing: 0.04em; 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; } /* 옵션 버튼 + 작은 팝업 메뉴 — 상단 자동토글 왼쪽. 클릭하면 팝업 토글, 안에 계좌 나누기 버튼 등을 담는다. */ .opts-wrap { position: relative; flex: none; display: inline-flex; } .opts-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: 13px; 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; } .opts-btn:hover { background: #232a3b; } .opts-btn:active { transform: scale(0.95); } .opts-btn[aria-expanded="true"] { background: #232a3b; border-color: #3a4256; } .opts-popup { position: absolute; top: calc(100% + 6px); right: 0; z-index: 60; background: #161b27; border: 1px solid #2a3142; border-radius: 10px; padding: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.45); display: flex; flex-direction: column; gap: 4px; min-width: 168px; } .opts-popup[hidden] { display: none; } /* 팝업 메뉴 항목 공통 스타일 — 가로 꽉 채우고 좌측 정렬 + 아이콘. 자립형(다른 버튼 클래스 없이도 동일). */ .opts-item { display: flex; align-items: center; justify-content: flex-start; width: 100%; min-width: 0; height: auto; padding: 9px 11px; gap: 9px; background: #1c2230; border: 1px solid #2a3142; color: #e6e6e6; border-radius: 8px; font-size: 13px; font-weight: 600; line-height: 1; font-family: inherit; cursor: pointer; user-select: none; text-align: left; transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s; } .opts-item:hover { background: #232a3b; } .opts-item:active { transform: scale(0.95); } .holdings-toggle-wrap { margin-top: 4px; } /* 등락률 계산기 모달 결과 영역 */ .calc-result { margin-top: 14px; padding: 12px 14px; background: #14171f; border: 1px solid #232838; border-radius: 10px; } .calc-line { display: flex; align-items: baseline; justify-content: space-between; padding: 4px 0; } .calc-lbl { font-size: 13px; color: #8b8f9a; } .calc-val { font-size: 22px; font-weight: 700; color: #8b8f9a; font-variant-numeric: tabular-nums; } .calc-hint { margin-top: 6px; font-size: 12px; color: #7a8493; } .calc-hint:empty { display: none; } .calc-sym-row { display: block; margin-top: 12px; font-size: 13px; color: #8b8f9a; } .calc-sym-row .order-symbol-select { margin-top: 5px; max-width: none; } .calc-sym-status { margin-top: 6px; font-size: 12px; color: #8b8f9a; min-height: 1em; } .calc-sym-status:empty { display: none; } .calc-price-row { display: flex; gap: 6px; align-items: center; } .calc-price-row input { flex: 1 1 auto; min-width: 0; } .calc-curbtn { flex: 0 0 auto; padding: 0 10px; height: 34px; border: 1px solid #2f6df0; border-radius: 8px; background: #1a2436; color: #7fb0ff; font-size: 12px; font-weight: 600; cursor: pointer; white-space: nowrap; } .calc-curbtn:active { background: #23324d; } .calc-sub { font-size: 11px; color: #6b7280; font-weight: 400; } .he-save-row { margin-top: 12px; } .he-save-btn { width: 100%; padding: 10px; border: 1px solid rgba(125,211,168,0.35); border-radius: 8px; background: rgba(125,211,168,0.1); color: #7dd3a8; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; } .he-save-btn:active { background: rgba(125,211,168,0.2); } .he-saved-wrap { margin-top: 16px; } .he-saved-title { font-size: 12px; color: #8b8f9a; margin-bottom: 6px; } .he-saved-list { display: flex; flex-direction: column; gap: 6px; } .he-saved-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: #14171f; border: 1px solid #232838; border-radius: 8px; } .he-saved-pick { flex: 1 1 auto; min-width: 0; text-align: left; background: none; border: none; color: #e8eaed; font-size: 13px; cursor: pointer; font-family: inherit; padding: 0; } .he-saved-pick .he-saved-meta { color: #8b8f9a; font-size: 12px; } .he-saved-edit { flex: 0 0 auto; background: none; border: none; color: #6b7280; font-size: 14px; cursor: pointer; padding: 0 4px; } .he-saved-edit:hover { color: #7dd3a8; } .he-saved-del { flex: 0 0 auto; background: none; border: none; color: #6b7280; font-size: 16px; cursor: pointer; padding: 0 4px; } .he-saved-del:hover { color: #ff4d5e; } .he-saved-pick.active { color: #ffcc33; } .he-add-row { margin-top: 10px; } .he-add-btn { width: 100%; padding: 10px; border: 1px dashed #3a4152; border-radius: 8px; background: transparent; color: #8b8f9a; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; } .he-add-btn:active { background: #14171f; } .he-result-headrow { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; } .he-result-head { font-size: 12px; color: #8b8f9a; min-width: 0; } .he-refresh-btn { flex: 0 0 auto; } .he-refresh-btn.hidden { display: none; } .he-merge-wrap { margin-top: 12px; padding: 10px; border: 1px dashed #2f6df0; border-radius: 8px; background: rgba(47,109,240,0.06); } .he-merge-wrap.hidden { display: none; } .he-merge-title { font-size: 12px; color: #8b8f9a; margin-bottom: 8px; } .he-detail-sep { height: 1px; background: #232838; margin: 8px 0; } .calc-line.he-detail { padding: 2px 0; } .calc-line.he-detail .calc-lbl { font-size: 12px; } .calc-line.he-detail .calc-val { font-size: 14px; font-weight: 600; color: #c9ccd3; } /* 차트 컨트롤 — 자산정보 탭 상단의 공통 기간·단위 select 묶음. */ .chart-controls { display: flex; flex-direction: column; gap: 6px; margin: 10px 14px 4px; } .chart-controls .cc-line { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; } .chart-controls .cc-line-ym[hidden] { display: none; } /* 지정기간 아닐 때 2번째 줄 숨김 ([hidden] UA 스타일이 flex에 가려지지 않도록 명시) */ .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,") 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; } /* 2번째 줄(유형/연도/월) — 연도/월은 내용 크기로(월 줄바뀜 방지), 유형은 기간과 동일 폭. 글씨는 1번째 줄과 동일. */ .chart-controls .cc-line-ym { gap: 8px; } .chart-controls .cc-line-ym .nw-select { min-width: 0; } .chart-controls .cc-line-ym .nw-select[data-nw-ymtype] { min-width: 96px; } /* 유형 = 기간 select 와 동일 폭 */ @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: 640px; max-height: calc(100dvh - env(safe-area-inset-top) - 56px); overflow-y: hidden; display: flex; flex-direction: column; transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; } .order-box > [data-order-step="1"] { display: flex; flex-direction: column; flex: 1; min-height: 0; } /* 매수/매도 모드 전체 색조 — 모달 어디를 봐도 side가 한눈에 (매수=빨강/매도=파랑) */ .order-box.side-buy { border-color: #c9303e; box-shadow: 0 0 0 1px #c9303e, 0 12px 44px rgba(201,48,62,0.34); background: linear-gradient(180deg, rgba(201,48,62,0.12), rgba(201,48,62,0.02) 130px, #0d1018 300px); } .order-box.side-sell { border-color: #2a5fb3; box-shadow: 0 0 0 1px #2a5fb3, 0 12px 44px rgba(42,95,179,0.34); background: linear-gradient(180deg, rgba(42,95,179,0.12), rgba(42,95,179,0.02) 130px, #0d1018 300px); } .order-box.side-buy .modal-head { border-bottom: 2px solid rgba(201,48,62,0.55); padding-bottom: 10px; } .order-box.side-sell .modal-head { border-bottom: 2px solid rgba(42,95,179,0.55); padding-bottom: 10px; } .order-box.side-buy .orderbook { border-color: rgba(201,48,62,0.38); } .order-box.side-sell .orderbook { border-color: rgba(42,95,179,0.38); } /* 주문 방향 확인 팝업 — 매수·매도 버튼을 직접 골라 누르게 함(위치 랜덤). 오타 방지 */ #order-confirm-modal .btn-confirm { font-size: 16px; font-weight: 700; padding: 15px 14px; } .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); grid-template-rows: minmax(0, 1fr); 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; color: #565c68; } .orderbook .ob-row:last-of-type { border-bottom: 0; } .orderbook .ob-row.ask { background: linear-gradient(to left, rgba(42,95,179,0.16), transparent 60%); color: #8db4ff; } .orderbook .ob-row.bid { background: linear-gradient(to left, rgba(201,48,62,0.16), transparent 60%); color: #ff8a95; } .orderbook .ob-row:hover { background: #1c1f29; } .orderbook .ob-row.ob-current { background-color: rgba(230,230,230,0.13); color: #fff; font-weight: 700; box-shadow: inset 3px 0 0 #e6e6e6; } .orderbook .ob-row.ob-synthetic { background: rgba(230,230,230,0.10); } .orderbook .ob-row.ob-synthetic .qty { opacity: 0.85; color: #d0d0d0; font-size: 6.5px; } .orderbook .ob-row.ob-cap { background: #12141b; } .orderbook .ob-cap-mark { display: inline-block; font-size: 6.5px; font-weight: 800; padding: 0 2px; border-radius: 2px; margin-right: 1px; vertical-align: middle; } .orderbook .ob-cap-mark.up { color: #ff8a95; background: rgba(201,48,62,0.28); } .orderbook .ob-cap-mark.down { color: #8db4ff; background: rgba(42,95,179,0.28); } .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-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 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; } /* overflow-x: hidden 필수 — overflow-y:auto 만 주면 브라우저가 가로도 auto 로 만들어, 내부가 조금만 넘쳐도 가로 스크롤이 생긴다(.orderbook 도 같은 이유로 명시). */ .order-inputs { display: flex; flex-direction: column; gap: 8px; min-height: 0; overflow-y: auto; overflow-x: hidden; } /* 폼 컨트롤이 부모보다 넓어지는 것 자체를 막는다 — select 는 내재 최소폭을 갖는다 */ .order-inputs input, .order-inputs select { max-width: 100%; box-sizing: border-box; } /* 입력이 모달 높이를 넘쳐도 매수/취소 버튼은 항상 하단 고정 — 잘림 방지 */ .order-inputs .order-actions { margin-top: auto; position: sticky; bottom: 0; background: #0d1018; padding-top: 8px; } .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; } .price-tick-buttons .advice-open-btn { padding: 5px 4px; border-radius: 5px; background: #1a1710; border: 1px solid #4a3c1a; color: #e6c86b; font-size: 11px; font-weight: 700; cursor: pointer; text-align: center; font-family: inherit; white-space: nowrap; } .price-tick-buttons .advice-open-btn:hover { border-color: #e6c86b; color: #f0d98a; } .price-tick-buttons .advice-open-btn:active { transform: translateY(1px); } .advice-box { max-width: 420px; } .advice-body { padding: 4px 2px; } .advice-sub { font-size: 11px; color: #9aa0ad; margin-bottom: 8px; line-height: 1.4; } .advice-rows { display: flex; flex-direction: column; gap: 6px; } .advice-row { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 11px; border-radius: 8px; background: #1a1f2c; border: 1px solid #2a3040; text-align: left; cursor: default; } .advice-row .advice-lbl { font-size: 12px; color: #c9ccd3; flex: 1; } .advice-row .advice-px { font-size: 14px; font-weight: 700; color: #e8eaf0; font-variant-numeric: tabular-nums; white-space: nowrap; } .advice-rel { font-size: 10px; } .advice-rel.up { color: #ff8a95; } .advice-rel.dn { color: #8ab6ff; } .advice-tapmark { font-size: 10.5px; font-weight: 700; color: #8ab6ff; white-space: nowrap; margin-left: 2px; } .advice-row.advice-tap { cursor: pointer; border-color: #34506e; background: #16202e; } .advice-row.advice-tap:active { background: #1e2c3e; } .advice-row.advice-info { opacity: 0.72; } .advice-why { margin: 10px 0 0; background: none; border: none; color: #8ab6ff; font-size: 12px; font-weight: 600; cursor: pointer; padding: 4px 2px; } .advice-hints { margin: 6px 0 2px; padding-left: 16px; font-size: 11.5px; color: #b7bcc7; line-height: 1.5; } .advice-hints li { margin-bottom: 5px; } .advice-hints b { color: #d6dae2; white-space: nowrap; } .order-adr-banner { padding: 8px 12px; margin-bottom: 12px; border-radius: 8px; font-size: 12.5px; font-weight: 600; line-height: 1.4; } .order-adr-banner.adr-over { background: #2a1418; border: 1px solid #5a1f28; color: #ff8a95; } .order-adr-banner.adr-under { background: #101b2e; border: 1px solid #1f3a5a; color: #8ab6ff; } .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; } .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; } /* iOS Safari는 포커스된 입력요소 font-size<16px면 자동 확대(줌) → 터치기기에서 폼 컨트롤 최소 16px 보장. blanket 규칙은 명시 font-size 없는 입력(UA 기본치)만 잡고, 아래 목록이 16px 미만 클래스 규칙을 소스순서로 덮는다. .order-pin-row input(22px) 등 의도적 큰 값은 특이도가 높아 불변, 데스크톱은 pointer:coarse 밖이라 그대로. */ @media (pointer: coarse) { input, select, textarea { font-size: 16px; } .igrp-select, .group-name-input, .info-search-input, .info-symbols-select, .order-symbol-select, .chart-controls .nw-select { font-size: 16px; } } ''' 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, chart_ym: str = '', avail: dict | None = None) -> str: """차트용 공통 컨트롤 — 기간 / 단위 / 모드(순자산·손익누적·기간손익) select. 기간에 '지정기간' 추가 → 유형(연도별/월별) + 연도 + 월(월별일 때) 보조 드롭다운. period(chart_ym) 활성 시 단위는 강제·비활성. 의미없는 단위(이번주+월별 등)는 disabled.""" avail = avail or {'years': [], 'months': {}} period = (chart_ym or '').strip() is_year = len(period) == 4 is_month = len(period) == 7 period_active = is_year or is_month years = avail.get('years') or [] months_map = avail.get('months') or {} allowed_units = NET_WORTH_ALLOWED_UNITS_BY_DAYS.get( selected_days, tuple(u for _, u in NET_WORTH_UNIT_PRESETS) ) range_opts = ''.join( f'' for lbl, d in NET_WORTH_CHART_PRESETS ) range_opts += f'' # 유형: 연도별(year) / 월별(month) — period_active 일 때만 노출. type_opts = ( f'' f'' ) cur_year = period[:4] if period_active else (years[0] if years else '') cur_month = period[5:7] if is_month else '' year_opts = ''.join( f'' for y in years ) # 월 — 월별일 때만 실제 월 목록(활성), 연도별이면 '-' 한 칸 비활성. if is_month: month_opts = ''.join( f'' for m in months_map.get(cur_year, []) ) _month_dis = '' else: month_opts = '' _month_dis = ' disabled' _customhide = '' if period_active else ' hidden' months_json = html.escape(json.dumps(months_map, ensure_ascii=False), quote=True) # 지정기간은 단위 일별 고정 → 단위 select 비활성, '일별' 선택 표시. unit_opts = ''.join( f'' for lbl, u in NET_WORTH_UNIT_PRESETS ) mode_opts = ''.join( f'' for lbl, m in NET_WORTH_MODE_PRESETS ) _unit_dis = ' disabled' if period_active else '' # 1번째 줄: 기간 / 단위 / 차트타입(모드). 2번째 줄(지정기간일 때만): 유형 / 연도 / 월. line1 = ( f'
    ' f'' f'' f'
    ' ) line2 = ( f'
    ' f'' f'' f'' f'
    ' ) return f'
    {line1}{line2}
    ' 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 색상·해석 라벨. ≤80 침체(매수권), ≥120 과열(매도권). 그래프 기준선(80/100/120)과 동일.""" if adr is None: return 'flat', '' if adr <= 80: return 'down', '침체' if adr >= 120: return 'up', '과열' return 'flat', '' def _market_adr_signal(market: str) -> dict | None: """거래 모달용 — 시장(KOSPI/KOSDAQ) 20일 정통 ADR + 과매수/과매도 판정. _adr_class와 동일 임계(≥120 과매수·≤80 과매도). 중립·20일 미만 누적이면 None(배너 미표시). 반환 {'value': float, 'zone': '과매수'|'과매도', 'label': '코스피'|'코스닥'}.""" if market not in ('KOSPI', 'KOSDAQ'): return None try: live_rows = _fetch_market_indicators() or [] today = next((r for r in live_rows if r.get('symbol') == market), None) ma, count, _ = _compute_adr_ma_latest(market, today) if ma is None or count < ADR_MA_WINDOW: return None if ma >= 120: zone = '과매수' elif ma <= 80: zone = '과매도' else: return None return {'value': round(ma, 1), 'zone': zone, 'label': _ADR_MARKET_LABELS.get(market, market)} except Exception: return None def _build_adr_series_with_live(history_rows: list[dict], live_pt: dict | None) -> list[dict]: """history(오래된 순) + 오늘 라이브 1점을 합쳐 [{date,adr,rise,fall,is_live}, ...] 반환. rise/fall(상승·하락 종목수)은 정통 합계식 ADR 산출용. 라이브는 history 마지막 date 와 다를 때만 append.""" base: list[dict] = [ {'date': r['date'], 'adr': float(r['adr']), 'rise': r.get('rise'), 'fall': r.get('fall'), 'is_live': False} for r in history_rows if r.get('adr') is not None and r.get('date') ] if live_pt and live_pt.get('adr') is not None and live_pt.get('date'): last_date = base[-1]['date'] if base else None if live_pt['date'] != last_date: base.append({'date': live_pt['date'], 'adr': float(live_pt['adr']), 'rise': live_pt.get('rise'), 'fall': live_pt.get('fall'), 'is_live': True}) return base def _adr_window_value(win: list[dict]) -> float: """정통 ADR = 윈도우 내 Σ상승종목 ÷ Σ하락종목 × 100. 비율의 평균(구방식)은 급등일에 왜곡되므로 합계식 사용. 윈도우 전 구간에 종목수(rise/fall)가 있고 Σ하락>0일 때만 합계식, 아니면 일별 비율 평균으로 폴백(구 데이터 graceful).""" rises = [w['rise'] for w in win if w.get('rise') is not None] falls = [w['fall'] for w in win if w.get('fall') is not None] if len(rises) == len(win) and len(falls) == len(win) and sum(falls) > 0: return sum(rises) / sum(falls) * 100 return sum(w['adr'] for w in win) / len(win) def _compute_adr_ma_latest(symbol: str, today_row: dict | None = None, window: int = ADR_MA_WINDOW) -> tuple[float | None, int, bool]: """심볼별 최근 N일 정통 ADR. (value or None, available_count, used_live). today_row = 오늘 라이브 행(adr·rise·fall·bizdate). available_count < window 이면 '누적 중 count/window'.""" history = _load_market_history(-1).get(symbol, []) live_pt = None if today_row and today_row.get('adr') is not None: bd = today_row.get('bizdate') iso = f'{bd[:4]}-{bd[4:6]}-{bd[6:]}' if (bd and len(bd) == 8) else today_row.get('date') if iso: live_pt = {'date': iso, 'adr': today_row['adr'], 'rise': today_row.get('rise'), 'fall': today_row.get('fall')} base = _build_adr_series_with_live(history, live_pt) if not base: return (None, 0, False) used = base[-window:] used_live = any(r.get('is_live') for r in used) # 확장 윈도우: window 미만이어도 가용한 만큼으로 계산(차트와 동일 정책). count로 누적 여부 표기. return (_adr_window_value(used), len(used), used_live) def _compute_adr_ma_series(rows: list[dict], window: int = ADR_MA_WINDOW) -> list[tuple[str, float, bool]]: """rows(오래된 순, {date,adr,rise,fall,is_live}) → 각 시점 trailing window 정통 ADR 시리즈. [(date, adr, is_live), ...]. 값은 Σ상승÷Σ하락×100 (급등일 왜곡 없는 표준식). 누적 < window(20일) 인 초반 구간은 정식 지표가 아니라 아예 점을 만들지 않는다 → 그래프 미표시.""" series: list[tuple[str, float, bool]] = [] for i in range(len(rows)): if i + 1 < window: # 20일 미만 누적 — 정식 20일 ADR 불가, 그래프에서 제외 continue win = rows[i + 1 - window : i + 1] val = _adr_window_value(win) is_live = any(r.get('is_live') for r in win) series.append((rows[i]['date'], val, is_live)) return series def _render_adr_info_table(rows: list[dict] | None) -> str: """ADR 기본정보 표 — 시장(코스피/코스닥)별 20일 ADR·오늘 ADR·종목수(상승/하락/보합). 20일·오늘 각각 ≤80 침체·≥120 과열 배지. 20일은 누적 미만이면 배지 없이 (n/20). 추세 그래프 밖 상시 노출.""" if not rows: return ( '
    ' + '
    시장 지표 미수신
    ' + '
    ' ) body: list[str] = [] for r in rows: # 20일 정통 ADR (배지·색은 20일 값 기준) ma, count, _ = _compute_adr_ma_latest(r['symbol'], r) if ma is None: ma_html = '' elif count >= ADR_MA_WINDOW: cls, tag = _adr_class(ma) ma_cls = cls if cls in ('up', 'down') else 'neutral' tag_html = f'{tag}' if tag else '' ma_html = f'{tag_html}{ma:.1f}' else: ma_html = f'{ma:.1f} ({count}/{ADR_MA_WINDOW})' # 오늘 raw ADR (색은 방향만: 100 초과=빨강/미만=파랑) raw_adr = r.get('adr') if raw_adr is None: today_html = '' else: raw_cls = 'up' if raw_adr > 100 else ('down' if raw_adr < 100 else 'neutral') tag_cls, tag = _adr_class(raw_adr) tag_html = f'{tag}' if tag else '' today_html = f'{tag_html}{raw_adr:.1f}' # 종목수 (상승↑/하락↓/보합→) rise, fall, steady = r.get('rise'), r.get('fall'), r.get('steady') if rise is not None and fall is not None and steady is not None: bd_html = ( f'{rise:,}↑ / ' f'{fall:,}↓ / ' f'{steady:,}→' ) else: bd_html = '' body.append( f'{html.escape(r["label"])}' f'{ma_html}{today_html}' f'{bd_html}' ) return ( '' '' '' '' + ''.join(body) + '
    시장20일오늘종목수 ↑/↓/→
    ' ) def _render_market_indicators_card() -> str: """자산정보 탭의 시장정보 sub-tab에 들어가는 오늘의 시장 카드. - 지수 섹션: 미국 정규장(ET 09:30~16:00) 시간이면 KOSPI 200 + MSCI Korea(EWY), 그 외 KOSPI/KOSDAQ 등락률. - 투자자별 매매: 시장 단위 (개인/외국인/기관) — 네이버 m.stock 비공식 API. 실패시 에러 카드.""" rows = _fetch_market_indicators() if not rows: return '
    시장 지표를 불러올 수 없습니다.
    ' # bizdate는 두 시장 동일 — 첫 행 기준으로 헤더 라벨링. bizdate = next((r['bizdate'] for r in rows if r.get('bizdate')), None) if bizdate and len(bizdate) == 8: date_label = f'{bizdate[4:6]}/{bizdate[6:8]}' else: date_label = '' # === 지수 섹션 (시간 무관 상시 표시) === # KOSPI / KOSDAQ (라이브 또는 종가, _fetch_indices) + MSCI Korea(EWY) + S&P 500 + 필라델피아 반도체(SOX). # KOSPI 200 은 라이브 데이터 부재로 KOSPI 와 거의 동일 → KOSPI 만 표시. all_indices = _fetch_indices() indices_by_label = {q.get('label'): q for q in all_indices if q.get('label')} extra = _fetch_extra_market_quotes() or [None] * 4 extra = (extra + [None] * 4)[:4] ewy, sox, vix, tnx = extra index_quotes = [ indices_by_label.get('KOSPI'), indices_by_label.get('KOSDAQ'), indices_by_label.get('S&P500'), sox, ewy, vix, tnx, ] index_hint = '국내·미국 주요 지수' index_descriptions = { 'KOSPI': ( '한국 유가증권시장에 상장된 보통주 전체로 산출하는 시가총액 가중 종합지수.\n\n' '• 1980년 1월 4일 = 100 기준\n' '• 약 800개 종목, 시총 약 2,500조원 규모\n' '• 한국 경제·대형주 sentiment 대표 지표\n' '• 외국인·기관 자금 흐름이 큰 영향\n\n' '거래시간 (KST)\n' '• 정규장: 09:00 ~ 15:30\n' '• NXT 야간: 08:00~09:00 + 15:30~20:00' ), 'KOSDAQ': ( '한국 코스닥시장 종합지수 — 중소·벤처·기술주 중심.\n\n' '• 1996년 7월 1일 = 1000 기준\n' ' (2004년 100 → 1000 재조정)\n' '• 약 1,700개 종목\n' '• 바이오·IT·2차전지 비중이 높음\n' '• 글로벌 기술주(나스닥·SOX) sentiment에 민감\n' '• 코스피보다 변동성·개인 비중이 큼' ), 'S&P500': ( 'Standard & Poor\'s가 1957년 도입한 미국 대형주 500개 시가총액 가중 지수.\n\n' '• 미국 상장 기업 시총의 약 80% 커버\n' '• 기관 투자자 표준 벤치마크\n' '• SPY ETF 시총 약 5,000억 달러\n\n' '거래시간\n' '• 정규장 ET 09:30 ~ 16:00\n' '• KST 22:30 ~ 05:00 (DST)\n\n' '한국 시장 다음날 개장 sentiment에 가장 큰 영향.' ), '필라델피아 반도체': ( 'PHLX Semiconductor Sector Index (^SOX) — 1993년 도입.\n\n' '• 미국 상장 반도체 30개 종목 시총 가중\n' '• Nvidia · TSMC ADR · Intel · AMD\n' ' ASML · Broadcom · Qualcomm 등 포함\n\n' '한국 삼성전자·SK하이닉스와 0.7~0.8 상관관계.\n' '글로벌 반도체 사이클 선행 지표로\n' '한국 IT 비중이 큰 코스피에 직접 영향.' ), 'MSCI Korea (EWY)': ( 'iShares MSCI South Korea ETF (NYSE 상장).\n\n' '• MSCI Korea Index 추종\n' '• 한국 시총 상위 large/mid cap 약 90종목 보유\n' '• 삼성전자·SK하이닉스·LG에너지솔루션\n' ' 현대차 등 대표 종목 포함\n\n' '미국 거래소 상장이라\n' '미국 정규장 시간(KST 22:30~05:00 DST)에 라이브 거래.\n' 'ADR·대표주의 미국 야간 등락이 EWY에 반영되어\n' '한국 시장 야간 sentiment 추정에 활용.' ), 'VIX (공포지수)': ( 'CBOE Volatility Index — 1993년 도입.\n' 'S&P 500 옵션의 30일 implied volatility를 연율화한 변동성 지수.\n' '"공포지수(Fear Gauge)" 별칭.\n\n' '구간 해석\n' '• 12 이하: 안일 (complacency)\n' '• 12 ~ 20: 안정\n' '• 20 ~ 30: 주의\n' '• 30 ~ 40: 공포 (시장 패닉)\n' '• 40 이상: 극단 위기 (2008·2020 수준)\n\n' 'VIX 상승 = 위험자산 회피\n' '→ 한국 외국인 매도 압력 증가.' ), '미국채 10년 (%)': ( '미국 10년 만기 국채(Treasury Note)의 연 수익률.\n\n' '연준 통화정책 · 인플레이션 기대 · 재정 적자가 반영되는\n' '글로벌 무위험 금리 기준.\n\n' '한국 시장 영향\n' '• 상승 → 자금이 채권으로 이동\n' ' → 한국 외국인 매도 압력 (위험자산 회피)\n' '• 하락 → 신흥국·한국 자금 유입 가능성 ↑\n\n' '구간 해석\n' '• 통상 3.5 ~ 5%대가 정상\n' '• 6% 이상은 시장 부담\n' '• 단기 금리와의 역전(yield curve inversion)은\n' ' 경기 침체 신호로 해석' ), } index_rows: list[str] = [] for q in index_quotes: if not q: continue direction = q.get('direction') or 'flat' sign = '+' if direction == 'up' else ('−' if direction == 'down' else '') price = q.get('price', '—') change = q.get('change', '0') pct = q.get('pct', '0.00') label = q.get('label', '') desc = index_descriptions.get(label) # 색상 — 양봉=빨강(.up), 음봉=파랑(.down), 보합=회색(.flat). market-table CSS 그대로 사용. # 설명 — ⓘ 버튼 클릭 시 idx-info-modal 팝업으로 표시. 가격/등락도 attr로 전달해 팝업 안에서 함께 표시. # 차트는 data-idx-symbol 가 있으면 모달에서 lazy fetch (/api/idx-chart?symbol=...) 후 SVG 렌더. symbol_attr = _IDX_CHART_SYMBOL.get(label, '') info_btn = ( f'' ) if desc else '' index_rows.append( f'' f'{html.escape(label)}{info_btn}' f'{html.escape(price)}' f'{sign}{html.escape(change)} ({sign}{html.escape(pct)}%)' f'' ) index_section = '' if index_rows: index_section = ( '
    ' '' f'{"".join(index_rows)}' '
    ' '
    ' ) deal_rows: list[str] = [] for r in rows: p_str, p_cls = _format_signed_billion(r.get('personal')) f_str, f_cls = _format_signed_billion(r.get('foreign')) i_str, i_cls = _format_signed_billion(r.get('institutional')) deal_rows.append( f'' f'{html.escape(r["label"])}' f'{p_str}' f'{f_str}' f'{i_str}' f'' ) date_suffix = f' {date_label}' if date_label else '' refresh_btn = ( '' ) return ( '
    ' f'
    오늘의 시장{date_suffix}{refresh_btn}
    ' + index_section + '
    ' '
    순매수 금액 · 단위 억원
    ' '' '' f'{"".join(deal_rows)}' '
    개인외국인기관
    ' '
    ' '
    ' ) def _load_market_history(days: int = ADR_TREND_DAYS) -> dict[str, list[dict]]: """state/market_indicators_history.jsonl에서 시장별 최근 days 행 로드. days=-1 → 전체. 반환: {'KOSPI': [{...오래된 순...}], 'KOSDAQ': [...]}. 파일 없거나 깨지면 빈 dict.""" if not MARKET_HISTORY_FILE.exists(): return {} try: rows: list[dict] = [] for ln in MARKET_HISTORY_FILE.read_text().splitlines(): ln = ln.strip() if not ln: continue try: rows.append(json.loads(ln)) except json.JSONDecodeError: continue except Exception as e: sys.stderr.write(f'market history load failed: {e}\n') return {} grouped: dict[str, list[dict]] = {} for r in rows: m = r.get('market') if not m: continue grouped.setdefault(m, []).append(r) for m in grouped: grouped[m].sort(key=lambda r: r.get('date', '')) if days >= 0 and len(grouped[m]) > days: grouped[m] = grouped[m][-days:] return grouped def _render_adr_trend_controls(selected_days: int) -> str: """ADR 추세 카드 상단 기간 버튼 그룹. localStorage(behive.adrTrendDays) 키로 보존. 클릭 시 클라이언트 JS의 document-level handler가 받아서 localStorage 저장 + load(fresh) 트리거.""" btns = ''.join( f'' for lbl, d in ADR_TREND_PRESETS ) return f'
    {btns}
    ' _ADR_MARKET_COLORS = {'KOSPI': '#fbbf24', 'KOSDAQ': '#34d399'} _ADR_MARKET_LABELS = {'KOSPI': '코스피', 'KOSDAQ': '코스닥'} def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str: """시장정보 sub-tab의 ADR 추세 카드. KOSPI/KOSDAQ 두 20일 이평선을 한 SVG 안에 합쳐 그린다. 저장 raw daily ADR은 stock.trade-journal launchd가 평일 21:00 누적, 오늘치 라이브는 _fetch_market_indicators(5분 SWR)에서. 차트는 각 시점의 trailing 20일 평균 — 누적 < 20일인 앞 구간은 평균 산출 불가라 점이 안 그려진다. 가이드선·날짜축은 양쪽 시장이 공유. 호버 시 한 세로선 + 시장당 점, 툴팁에 두 값 동시 표시.""" # 이평 산출엔 days 윈도우 밖의 과거 19일도 필요 → 전체 로드 후 trim. history_all = _load_market_history(-1) live_rows = _fetch_market_indicators() or [] live_by_market: dict[str, dict] = {} for r in live_rows: sym = r.get('symbol') adr = r.get('adr') bd = r.get('bizdate') if not sym or adr is None: continue iso = f'{bd[:4]}-{bd[4:6]}-{bd[6:]}' if (bd and len(bd) == 8) else None if iso: # rise/fall 도 실어야 오늘 포함 윈도우가 정통 합계식으로 계산됨(없으면 그 구간만 비율평균 폴백). live_by_market[sym] = {'adr': adr, 'date': iso, 'rise': r.get('rise'), 'fall': r.get('fall')} # 시장별 trailing 20일 이평 시리즈 — (date, ma, is_live). # raw 시리즈(history+오늘 라이브)에서 시점별 trailing 평균 산출 후, days 파라미터로 마지막 N개 trim. 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 # 20일 ADR·오늘 ADR·종목수를 표(그래프 밖 상시)로 표시. adr_table = _render_adr_info_table(live_rows) # 그래프 펼침 토글 버튼 — 클릭 시 .adr-graph-wrap hidden 토글(순수 클라이언트, localStorage 보존). graph_toggle = ( '' ) # 두 시장 합쳐 점이 하나도 없으면 누적 부족 안내. all_dates = sorted({d for pts in market_pts.values() for d, _, _ in pts if d}) if not all_dates: hint = (f'{ADR_MA_WINDOW}일 누적 중 ({raw_count_max}/{ADR_MA_WINDOW}) — ' f'매 영업일 21:00 raw ADR 적재, {ADR_MA_WINDOW}일 채워지면 이평선 표시') return ( '
    ' '
    ADR 추세
    ' + adr_table + graph_toggle + '' + '
    ' ) date_to_i = {d: i for i, d in enumerate(all_dates)} n_dates = len(all_dates) # y축 스케일 — 0~200 고정이면 데이터(통상 40~90)가 아래에 눌려 밋밋함. 데이터 범위에 맞추되 # 기준선 80/100/120 이 항상 보이도록 그 밴드를 범위에 포함시킨다. _all_vals = [v for sym in ('KOSPI', 'KOSDAQ') for _, v, _ in market_pts[sym]] if _all_vals: _dmin, _dmax = min(_all_vals), max(_all_vals) else: _dmin, _dmax = 80.0, 120.0 _lo, _hi = min(_dmin, 80.0), max(_dmax, 120.0) _pad = max(6.0, (_hi - _lo) * 0.1) Y_MIN, Y_MAX = max(0.0, _lo - _pad), _hi + _pad # 차트 면적 — 두 라인 + 가이드 + 마커 라벨 여유. height는 sparkline 단일 70 대비 95 로 확장. 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)) # 가이드선 — 기준 80/100/120. y축이 이 밴드를 포함하므로 셋 다 표시. _guide_specs = [ (100, '#8b8f9a', '2 4', 0.25, 0.4), (80, '#4a8cf0', '3 3', 0.25, 0.45), (120, '#ff4d5e', '3 3', 0.25, 0.45), ] guides = '' for gv, gcolor, gdash, gline_op, gtext_op in _guide_specs: if not (Y_MIN <= gv <= Y_MAX): continue gy = y_for(gv) guides += ( f'' f'{gv}' ) # 시장별 이평선 path + 마지막 마커. 이평은 매끄러운 단일 시리즈라 saved/live 분리 점선 불필요. # 마지막 점이 오늘 라이브 raw를 포함한 평균이면 (is_live) open circle + 별표 마커. line_parts: list[str] = [] marker_parts: list[str] = [] for sym in ('KOSPI', 'KOSDAQ'): pts = market_pts[sym] if not pts: continue color = _ADR_MARKET_COLORS[sym] if len(pts) >= 2: d_attr = ' L '.join(f'{x_for(date_to_i[d]):.1f} {y_for(v):.1f}' for d, v, _ in pts) line_parts.append(f'') last_d, last_v, last_is_live = pts[-1] lx, ly = x_for(date_to_i[last_d]), y_for(last_v) if last_is_live: marker_parts.append( f'' f'{last_v:.1f}*' ) else: marker_parts.append( f'' f'{last_v:.1f}' ) # 호버 인디케이터 — 세로선 1개 + 시장당 점 2개 (없는 시장은 JS가 hidden 처리). hover_g = ( f'' f'' f'' f'' f'' ) # 포인트 메타 — 날짜축 i 기준. 각 날짜에 두 시장 값(있는 만큼) 포함. # JS는 가장 가까운 x 찾고 양쪽 시장 점에 dot 배치 + 툴팁 두 줄 표시. pts_meta: list[dict] = [] for i, d in enumerate(all_dates): entry: dict = {'d': d, 'x': round(x_for(i), 2)} for sym in ('KOSPI', 'KOSDAQ'): found = next(((pv, plive) for pd, pv, plive in market_pts[sym] if pd == d), None) if found is not None: pv, plive = found key = 'kospi' if sym == 'KOSPI' else 'kosdaq' entry[key] = {'v': round(pv, 2), 'y': round(y_for(pv), 2), 'live': bool(plive)} pts_meta.append(entry) pts_json = html.escape(json.dumps(pts_meta, separators=(',', ':')), quote=True) # 선택 지점 정보 — 차트 아래 정적 정보줄(구 플로팅 툴팁 대체). 기본은 최신 지점, 차트를 짚으면 갱신. # 시장 값 비어있으면 JS가 해당 줄 hide. info_html = ( '
    ' '' '코스피' '코스닥' '
    ' ) # 시장 색 범례 — 기간 표시는 차트 하단 x축으로 이동(축 라벨로 더 적합). legend_html = ( '
    ' f'코스피' f'코스닥' '
    ' ) # 차트 x축 하단 기간 라벨 — 시작/끝 날짜만 양 끝에 작게. first_dt, last_dt = all_dates[0], all_dates[-1] x_axis_y = H - 3 if first_dt == last_dt: x_axis_label = ( f'{html.escape(first_dt[5:])}' ) else: x_axis_label = ( f'{html.escape(first_dt[5:])}' f'{html.escape(last_dt[5:])}' ) chart_row = ( f'
    ' f'' f'{guides}' f'{"".join(line_parts)}' f'{"".join(marker_parts)}' f'{x_axis_label}' f'{hover_g}' f'' f'{info_html}' f'
    ' ) # 누적 < window 면 아직 정식 20일 이평이 아니라 '가용한 만큼'의 평균 → 제목에 명시. accum_note = f' · 누적 {raw_count_max}/{ADR_MA_WINDOW}일' if raw_count_max < ADR_MA_WINDOW else '' return ( '
    ' f'
    ADR 추세' + accum_note + '
    ' + adr_table + graph_toggle + '' + '
    ' ) 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, chart_ym: str = '') -> str: """자산정보 탭 — sub-tab(자산보기/차트보기/시장정보)로 분리. - 자산보기(기본): owner별 compact KPI 카드 스택 (합산 / 각계좌별도 토글) - 차트보기: 기간·단위·모드 select + owner별 차트 (순자산 / 손익누적 토글) - 시장정보: 오늘의 ADR·투자자별 매매 카드 + ADR 추세 sparkline (기간 select, 라이브 포인트) 추가 키움 호출 없음 (_fetch_all_data가 모은 owner_data 재사용). 시장 카드는 네이버 m.stock 별도 fetch (5분 SWR).""" if not ordered_owners: return '
    계좌 데이터가 없습니다.
    ' balances = balances or {} journal_by_label = journal_by_label or {} # 연도별/월별 절대 선택 — chart_ym('YYYY'=연도별·월별집계 / 'YYYY-MM'=월별·일별) 있으면 단위 강제 + period 필터. period = (chart_ym or '').strip() eff_unit = 'day' if period else chart_unit # 지정기간(연도별·월별)은 단위 일별 고정. # 절대 선택이 과거 기간(이번달·올해가 아님)이면 오늘 라이브 점·라이브 갱신을 끈다 — # 과거 차트(예: 4월)에 today 순자산이 마지막 점·델타·하단값으로 섞이는 것 방지. 프리셋은 항상 today 포함. _now_dt = datetime.now(KST) period_is_current = True if period: period_is_current = (period == _now_dt.strftime('%Y')) if len(period) == 4 else (period == _now_dt.strftime('%Y-%m')) consolidated_parts: list[str] = [] by_account_parts: list[str] = [] chart_parts: list[str] = [_render_chart_controls(chart_days, eff_unit, chart_mode, chart_ym=period, avail=_available_chart_periods())] 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=eff_unit, with_prev_baseline=True, period=period) 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], eff_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 period_is_current else None 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=eff_unit, cash_flow_by_date=None, # pnl·period 모두 cf 영향 이미 제거 — 마커 숨김 today_cash_flow=None, mode=chart_mode, prev_baseline_point=prev_baseline, rt_owner=(owner if period_is_current else None), rt_base_net=d.get('total_net'), )) else: # net 모드도 baseline 점(예: 5/1) prepend → 첫 영업일 변동이 0 기점에서 분기되는 모양. baseline 구간은 점선. chart_parts.append(_render_net_worth_chart( chart_series, current_net=(d.get('total_net') if period_is_current else None), selected_days=chart_days, selected_unit=eff_unit, cash_flow_by_date=cf_by_date, today_cash_flow=today_cf, mode='net', prev_baseline_point=prev_baseline, rt_owner=(owner if period_is_current else None), rt_base_net=d.get('total_net'), )) market_card = _render_market_indicators_card() adr_trend_card = _render_adr_trend_card(days=adr_days) # 자산보기 sub-panel 안 합산/각계좌별도 panes (양쪽 prerender). 토글 버튼은 페이지 상단(자동 토글 옆) 1곳으로 통일. assets_inner = ( '' '' ) return ( '
    ' + assets_inner + '
    ' '' '' '
    ' '' '' '' '
    ' ) def _render_watchlist_panel(cards: list[dict]) -> tuple[str, int]: if not cards: return '
    감시종목이 비어있습니다.
    ', 0 market_active = _market_active_now() show_day_change = _show_day_change_now() alerts_map = _load_alerts_map() for c in cards: c['_show_day_change'] = show_day_change c['_alerted'] = alerts_map.get(c.get('stock') or '', []) active = [c for c in cards if c.get('status') != 'pending_delete'] trash = [c for c in cards if c.get('status') == 'pending_delete'] held = sorted([c for c in active if c.get('mode') == 'held'], key=lambda c: c.get('held_qty', 0) * (c.get('held_avg_price') or 0), reverse=True) watching = sorted([c for c in active if c.get('mode') != 'held'], key=lambda c: c.get('saved_at', ''), reverse=True) trash.sort(key=lambda c: c.get('pending_delete_at', ''), reverse=True) sections: list[str] = [] if held: sections.append(f'') sections.append('\n'.join(_render_row(c) for c in held)) if watching: sections.append(f'') sections.append('\n'.join(_render_row(c) for c in watching)) if trash: sections.append(f'') sections.append('\n'.join(_render_row(c) for c in trash)) return '\n'.join(sections), len(cards) def _interest_menu_html(reg: dict) -> str: """풀다운 오른쪽 '+' 버튼 + 드롭다운 메뉴(종목 추가 / 그룹 관리). 관심종목 패널 상단 컨트롤 행에 둔다. 그룹 관리 항목에 현재 그룹명·종목수를 실어보내 모달을 서버 왕복 없이 즉시 채운다.""" all_groups = reg.get('groups', []) counts: dict = {} for names in reg.get('members', {}).values(): for n in names: counts[n] = counts.get(n, 0) + 1 all_attr = html.escape(','.join(all_groups), quote=True) counts_attr = html.escape(json.dumps(counts, ensure_ascii=True), quote=True) return ( '
    ' '' '' '
    ' ) def _render_interests_edit_modal() -> str: """등록된 관심종목의 매수가/목표가/손절가/메모 수정 모달. shell HTML 직속에 둠. 삭제 버튼은 모달 내부에 별도 form(interest-delete-form 클래스)로 두고 HTML5 form attribute로 modal-actions의 submit 버튼에 연결 — 행 actions에서 빼냈다.""" return ( '' ) def _render_tag_modal() -> str: """종목 공용 태그 설정 모달. 어느 탭의 칩이든 +태그 버튼이든 동일하게 연다. 대장 버튼은 토글 — 클릭 즉시 set/clear & submit. 자유 입력은 텍스트칸 + 저장.""" return ( '' ) def _render_note_modal() -> str: """종목별 자유 메모 모달. 어느 탭의 '메모' 버튼이든 동일하게 연다. 저장은 텍스트 그대로, 삭제는 빈 텍스트 submit 으로 항목 제거.""" return ( '' ) def _render_calc_modal() -> str: """등락률 계산기 모달. ☰ 옵션 팝업의 '계산기' 항목이 연다. 기준가·현재가를 입력하면 등락률(%)과 차이 금액을 클라이언트에서 즉시 계산. 서버 왕복·키움 호출 없음.""" return ( '' ) def _render_avg_calc_modal() -> str: """평단 계산기(물타기) 모달. ☰ 옵션 팝업의 '평단 계산기' 항목이 연다. 보유수량·현재평단가·추가매수 주수·추가매수가 → 새 평단·총수량·총매입금 즉시 계산(클라이언트). '내 계좌 종목'을 고르면 보유수량·현재평단가 자동 입력(/api/holdings, code별 전 계좌 가중평균).""" return ( '' ) def _render_holdeval_modal() -> str: """보유 평가 계산기 모달. ☰ 옵션 팝업의 '보유 평가 계산기' 항목이 연다. 저장된 목록을 먼저 보여주고, 항목을 누르면 현재가를 조회해 등락률·평가손익·매입/평가금액을 표시. 하단 '추가' 버튼이 입력 팝업(holdeval-add-modal)을 연다. /api/holdeval/* + /api/quote_book 재사용.""" return ( '' ) def _render_holdeval_add_modal() -> str: """보유 평가 계산기 '추가' 팝업(중첩 modal-top). 이름·종목검색·평단가·주수 입력 후 저장하면 holdeval-modal 목록에 추가된다. 현재가는 저장하지 않고 조회 시점에 라이브로 가져오므로 여기선 안 받는다.""" return ( '' ) def _render_group_assign_modal() -> str: """관심종목의 그룹 소속 편집 모달. 어느 관심종목 행이든 '그룹' 버튼이 동일하게 연다. 체크박스는 JS가 data-group-all 로 렌더·data-group-current 로 체크. 새 그룹 인라인 추가 가능.""" return ( '' ) def _render_group_manage_modal() -> str: """관심종목 그룹 레지스트리 관리 모달. 생성/이름변경/삭제/순서변경. 각 op는 keepOpen fetch로 /interests/groups 호출 후 응답 groups 로 리스트 재렌더.""" return ( '' ) def _render_candidate_modal() -> str: """다중 매칭 시 사용자가 종목을 선택하는 별도 팝업. 추가 모달과 분리된 z-index 위층.""" return ( '' ) def _render_stock_name_modal() -> str: """거래내역 표에서 종목 셀 길게 누르면 풀네임 표시 — press-and-hold tooltip. 셀 위쪽으로 띄워 손가락에 가리지 않게. pointerdown=show / pointerup=hide.""" return ( '' ) def _render_trade_modal() -> str: """종목별 거래내역 팝업. shell HTML 직속이라 panels swap 영향 없음. trigger 클릭 → fetch → table 렌더.""" return ( '' ) def _render_info_modal() -> str: """종목별 기업정보 팝업 (시총·PER·PBR·EPS·BPS·ROE·52주·외국인비율 등). shell HTML 직속이라 panels swap 영향 없음. '상세' 버튼 클릭 → /api/stock_info fetch → 렌더.""" return ( '' ) def _render_info_desc_modal() -> str: """지수 설명 팝업. info-modal 위에 겹쳐 뜨는 modal-top. ? 버튼 클릭 시 용어·설명 표시.""" return ( '' ) def _render_advice_modal() -> str: """기술적 권장가 팝업. order-modal 위에 겹쳐 뜨는 modal-top. [💡 권장가 보기] 클릭 시 표시. 가격 행 탭 → 단가 자동입력(호가단위 스냅) 후 팝업 닫힘. ▸ 근거로 계산식 설명 토글.""" return ( '' ) def _render_order_modal() -> str: """주문 진입 모달 (매수/매도). 호가창 1초 폴링, PIN OTP 흐름. 1단계: 종목·계좌·수량·단가 입력 → /api/order/propose (PIN 텔레그램 발송) 2단계: PIN 입력 → /api/order/verify (실주문 실행) 3단계: 결과 표시 """ return ( '' ) def _render_open_orders_modal() -> str: """매매등록된 미체결 주문 목록 팝업. [📋 진행중] 탭이 표시 — 4계좌 통합 + 행별 취소. 각 행: 종목·계좌·방향·수량·단가·상태 + [취소] 버튼 → POST /api/orders/cancel. """ return ( '' ) def _render_pin_modal() -> str: """매매 카드 PIN 입력 팝업. order-modal의 propose 성공 직후 띄움. 카드 요약 + PIN 입력 칸 + 만료 카운트다운 + [카드 취소][주문 실행] 액션. verify 결과는 같은 모달 안 결과 영역으로 swap. """ return ( '' ) def _render_sell_choice_modal() -> str: """매도 시 같은 소유자 그룹의 다른 계좌에도 같은 종목 보유 중일 때 띄우는 선택 팝업. [두 계좌 모두 매도] / [선택 계좌만 매도] / [취소] 3버튼. order-modal 위에 겹침(modal-top). """ return ( '' ) def _render_order_confirm_modal() -> str: """매수/매도 버튼 → propose 직전 방향 확인 팝업. 매수/매도 오타 방지용. 매수·매도 버튼을 둘 다 띄우고 관리자님이 직접 맞는 방향을 찾아 누르게 함(위치 랜덤). 주문 방향과 일치하면 진행, 다르면 팝업 닫고 취소. order-modal 위에 겹침(modal-top). """ return ( '' ) def _render_interests_modal() -> str: """shell HTML 직속에 두는 종목 추가 모달. panels API의 swap 영역(section.tab-content) 밖이라 자동 갱신 중에도 DOM·입력값이 보존된다.""" return ( '' ) def _render_interests_panel(cards: list[dict]) -> tuple[str, int]: sections: list[str] = [] reg = _load_interest_groups() menu_html = _interest_menu_html(reg) # 풀다운 오른쪽 '+' 메뉴 (종목 추가 / 그룹 관리) if not cards: sections.append( f'
    관심0{menu_html}
    ' ) sections.append('
    관심종목이 비어있습니다.
    ') return '\n'.join(sections), 0 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) ordered = held + watching # 전체·그룹 공용 정렬 (보유중 먼저, 관심은 저장순) # 그룹 풀다운 — 레지스트리 순서대로 멤버 있는 그룹만 옵션으로. 다중 소속은 각 패널에 중복 노출. # 보유중은 mode=='held' 로 자동 채워지는 옵션(레지스트리 미등록). 선택 상태는 .sub-tab(자산탭)과 # 충돌 없게 전용 클래스 .igrp-select / .igrp-panel 사용. members = reg.get('members', {}) group_order = reg.get('groups', []) def _card_groups(c): return [g for g in (members.get(c.get('stock') or '') or []) if g in group_order] active_groups = [g for g in group_order if any(g in _card_groups(c) for c in ordered)] ungrouped = [c for c in watching if not _card_groups(c)] # 미분류 = 미보유 & 그룹 미배정 if not held and not active_groups: # 보유·그룹 없음 — 단일 목록 (풀다운 없이 라벨 + '+' 메뉴만). sections.append( f'
    관심{len(watching)}{menu_html}
    ' ) sections.append('\n'.join(_render_row(c, source='interests') for c in watching)) else: tabs = [('__all__', '전체', ordered)] if held: tabs.append(('__held__', '보유중', held)) for g in active_groups: tabs.append((g, g, [c for c in ordered if g in _card_groups(c)])) if ungrouped: tabs.append(('__ungrouped__', '미분류', ungrouped)) opts = ''.join( f'' for i, (key, label, bucket) in enumerate(tabs) ) sections.append( f'
    {menu_html}
    ' ) panels = ''.join( f'
    ' + '\n'.join(_render_row(c, source='interests') for c in bucket) + '
    ' for i, (key, label, bucket) in enumerate(tabs) ) sections.append(panels) 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, chart_ym: str = '') -> 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, chart_ym=chart_ym) 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, 'build': BUILD, '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') # 계단 프리셋은 셸에 실어 보낸다 — 거래 모달을 열 때마다 fetch 하면 느리고, # 고정 4종이라 셸에 넣어도 부담이 없다. trail_presets_json = json.dumps(TRAIL_PRESETS, ensure_ascii=False) nav_html = '\n'.join( f'{html.escape(label)}' for tid, label in descs ) spinner_placeholder = ( '
    ' '불러오는 중…
    ' ) content_html = '\n'.join( f'
    {spinner_placeholder}
    ' for tid in valid_tids ) show_rules = ',\n'.join( f'html[data-tab="{tid}"] section[data-tab="{tid}"]' for tid in valid_tids ) active_rules = ',\n'.join( f'html[data-tab="{tid}"] nav.tabs a[href="#{tid}"]' for tid in valid_tids ) dynamic_css = ( f'{show_rules},\n' f'html:not([data-tab]) section[data-tab="{first_tid}"] {{ display: block; }}\n' f'{active_rules},\n' f'html:not([data-tab]) nav.tabs a[href="#{first_tid}"] ' f'{{ color: #f0f0f0; border-bottom-color: #ff4d5e; }}\n' ) valid_tids_js = ','.join(f"'{tid}'" for tid in valid_tids) # 탭 활성 동기화 — URL hash → html[data-tab]. CSS-only 전환이라 클릭은 즉시. # 자산정보 탭으로 진입할 때는 전체 fetch 트리거 — 모든 owner를 한 화면에 모아 보는 요약 탭이라 stale 최소화. head_script = ( '' ) # 패널 fetch + DOM swap. # load() — 전체 (모든 탭 채움, 첫 진입·새로고침·PTR·자산정보 탭 진입) # load("self"|"gahee") — owner 탭 하나만 받아 partial swap (자동 갱신) # 응답 tabs는 받은 만큼만 swap하므로 보지 않는 owner의 stale 데이터는 그대로 둠. # inFlight를 key별로 분리해 owner 갱신과 전체 fetch가 충돌하지 않도록. panels_script = ( '' ) # 순자산 차트 인터랙션: 호버/터치 시 indicator 세로선 + 툴팁(그날 손익·누적 손익). # data-pts JSON 파싱 → pointermove로 최근접 포인트 찾아 indicator·tooltip 갱신. # MutationObserver로 swap 후 새 .net-chart 자동 재바인딩. netchart_script = ( '' ) # ADR 통합 차트 인터랙션 — 터치/호버 시 세로선 + 시장당 점 + 듀얼 툴팁(날짜·코스피·코스닥). # data-adr-pts JSON 각 entry: {d, x, kospi?:{v,y,live}, kosdaq?:{v,y,live}}. # 영속화: hide 타이머 없음 + module 스코프 selectedDate 에 마지막 선택 날짜 저장 → # 자동 갱신(panels innerHTML swap) 후 init 시 동일 날짜로 indicator 복원. adr_hover_script = ( '' ) # 보유 카드 SVG 봉차트 — 카드 펼침 시 /api/chart_svg fetch. # dailyCache: code -> 1Y/6M/1M HTML fragment. # minuteCache: code:unit -> {svg, ts} (TTL 30s — 오늘 마지막 봉이 분 단위로 흐름). # activeRange: code -> 마지막 활성 range. panels swap 후 1Y 로 reset 되는 거 방지. # panels-loaded 이벤트마다 열린 카드 daily 복원 + activeRange 적용. 활성이 분봉이면 자동 재fetch. chart_svg_script = ( '' ) # pull-to-refresh: 임계 넘으면 fetch(__behive_load) + spinner 유지, 완료 후 reset. reload() 안 함. # iOS PWA엔 네이티브 PTR 부재라 직접 구현. resistance 0.55로 손맛. ptr_script = ( '' ) # 시장 상태 배지 + 개장/마감 전환 감지 (2026-07-22 이벤트 기반 전환). # ⚠️ 3초 주기 전면 패널 swap 폐지 — 매 3초
    innerHTML 통째 교체가 스크롤·열린 UI를 # 파괴해 기능마다 "3초에도 유지" 처리를 반복해야 했음. 갱신은 이제 이벤트가 담당: # · 시세·손익·KPI → realtime_script 1초 틱(WS) # · 체결·주문(접수/취소) → state/fill_signal bump → 1초 틱이 감지해 필요할 때만 1회 갱신 # 여기 스크립트는 상단 상태 점(🟢장중/⚪장마감)만 유지 + 개장/마감 전환 시 1회 재렌더 # (day-change 게이트·시간대 표시 반영). 정상 상태에선 주기적 full swap 없음. market_active_js = 'true' if market_active else 'false' auto_reload_script = ( '' ) # 더블탭 zoom 차단 — iOS Safari가 `touch-action: manipulation`을 무시할 때 폴백. # 300ms 안에 두 번째 touchend가 들어오면 preventDefault → 시스템 zoom 동작·동시 발생하는 ghost click 둘 다 억제. # 첫 탭의 click은 정상 dispatch되므로 details 토글·새로고침 버튼 등 단일 탭 UX 영향 없음. # 예외: 하단 지수 바(#market-ticker)는 탭할 때마다 묶음을 넘기는 UI라 빠른 연타가 정상 사용이다. # 여기서 걸러내면 두 번째 탭부터 click이 안 와서 한 칸씩 건너뛴다. 그 바만 억제 대상에서 뺀다 # (더블탭 zoom은 html,body의 touch-action: manipulation이 1차로 막고, 이 바는 36px 최하단 띠라 위험이 작다). nodbltap_script = ( '' ) # 등락률 계산기 모달 로직 — 기준가·현재가 입력 즉시 % / 차이 금액 계산 (순수 클라이언트). calc_script = ( '' ) # 평단 계산기(물타기) 로직 — 보유수량·현재평단가·추가매수 주수·추가매수가 즉시 계산 (순수 클라이언트). avg_calc_script = ( '' ) # 보유 평가 계산기 로직 — 임의 종목 검색(현재가 자동) + 평단가·주수 → 등락률·평가손익·매입/평가금액 (순수 클라이언트). holdeval_script = ( '' ) idx_info_modal_html = ( '' ) interest_modal_html = _render_interests_modal() candidate_modal_html = _render_candidate_modal() interest_edit_modal_html = _render_interests_edit_modal() tag_modal_html = _render_tag_modal() note_modal_html = _render_note_modal() calc_modal_html = _render_calc_modal() avg_calc_modal_html = _render_avg_calc_modal() holdeval_modal_html = _render_holdeval_modal() holdeval_add_modal_html = _render_holdeval_add_modal() group_assign_modal_html = _render_group_assign_modal() group_manage_modal_html = _render_group_manage_modal() trade_modal_html = _render_trade_modal() kpi_modal_html = _render_kpi_modal() info_modal_html = _render_info_modal() info_desc_modal_html = _render_info_desc_modal() advice_modal_html = _render_advice_modal() order_modal_html = _render_order_modal() pin_modal_html = _render_pin_modal() sell_choice_modal_html = _render_sell_choice_modal() order_confirm_modal_html = _render_order_confirm_modal() open_orders_modal_html = _render_open_orders_modal() stock_name_modal_html = _render_stock_name_modal() # 모달 열기/닫기 + 폼 fetch — 트리거 버튼은 panels swap으로 다시 그려지므로 이벤트 위임. # 모달 DOM 자체는 shell HTML 직속이라 swap 영향 받지 않음 → 입력값 보존. # /interests/add 응답은 Accept: application/json 시 JSON. 다중 매칭이면 후보 버튼 표시. modal_script = ( '' ) # 거래내역 종목 셀 press-and-hold tooltip — pointerdown=show, pointerup/cancel/scroll=hide. # 셀 상단에 띄워 손가락에 안 가리게. 화면 위 공간 부족하면 셀 하단으로 fallback. stock_name_tip_script = ( '' ) # details.row[data-row-key] 그룹 mutex — 새로 열리면 다른 열려있던 것 자동 닫음. # capture phase 사용: toggle 이벤트는 bubble 안 함. panels apply가 다시 그릴 때 setAttribute('open') # 호출이 다시 trigger되어도 한 개만 남는다. # ⚠️ 관심종목 그룹 탭은 같은 종목(=같은 data-row-key)이 전체·보유중·그룹 패널에 중복 존재한다. # 같은 키끼리는 닫지 않는다(탭 간 동기화, 새로고침 시 보이는 탭 행이 닫히던 버그). 다른 종목만 닫음. details_mutex_script = ( '' ) # 주문 모달 + PIN 모달 JS — 호가 1초 폴링, 매수/매도, 호가 클릭 → 단가, 계좌 select → /api/order/check. # propose 성공 시 별도 PIN 모달(pin-modal)로 swap. verify는 PIN 모달 안에서 처리. # window.openOrderModal({code, name, side?, account?, accounts?, price?}) — trigger 진입점. order_modal_script = r'''''' # 실시간 시세 갱신 — /api/realtime/quotes(허브 메모리) 1초 폴링으로 행 가격·VI 배지를 라이브 갱신. # 허브 콜드/끊김이면 응답이 비어 아무것도 안 함(REST로 그려진 값 유지). visibility 가드. realtime_script = r''' ''' # 수급 블록 — 팝업 .flow-box 를 열 때 lazy fetch(/api/investor_flow). 5일 표 + 더보기(20일 오버레이). flow_script = r'''''' return f''' 자산현황 {head_script}

    자산현황

    갱신 {now}
    {content_html}
    지수 불러오는 중…
    {idx_info_modal_html} {interest_modal_html} {interest_edit_modal_html} {tag_modal_html} {note_modal_html} {calc_modal_html} {avg_calc_modal_html} {holdeval_modal_html} {holdeval_add_modal_html} {group_assign_modal_html} {group_manage_modal_html} {candidate_modal_html} {trade_modal_html} {kpi_modal_html} {info_modal_html} {info_desc_modal_html} {advice_modal_html} {order_modal_html} {pin_modal_html} {sell_choice_modal_html} {order_confirm_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} {calc_script} {avg_calc_script} {holdeval_script} {details_mutex_script} {modal_script} {order_modal_script} {stock_name_tip_script} {flow_script} {realtime_script} ''' # ── 패널 페이로드 인메모리 캐시 ── # /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() # 외부 매매(키움 앱 등) 감지 — fill_watcher는 웹/orders 경유 주문만 fill_signal을 bump하므로, # 앱에서 직접 매매해 바뀐 보유수량은 이벤트 신호가 없다. 전 계좌 보유 시그니처를 브라우저가 짧게 # 폴링(20s)해 값이 바뀌면 패널을 1회 갱신. 서버는 TTL 캐시로 kt00018 콜을 20s당 1회로 제한 # (다중 탭·기기 공유). 폐지된 3초 전면 swap 대비 훨씬 가볍고, 브라우저는 장중에만 폴링. _HOLDINGS_SEQ_TTL = 20.0 _holdings_seq_cache: dict = {'seq': None, 'expires_at': 0.0} _holdings_seq_lock = threading.Lock() # 종목별 외국인·기관·개인 수급 (ka10059) — 팝업 lazy fetch용. code별 TTL 캐시(재열람 시 재조회 방지). _FLOW_TTL = 120.0 _flow_cache: dict = {} # code -> (rows, expires_at) _flow_lock = threading.Lock() def _holdings_seq() -> str: """전 계좌 보유 (code:qty:당일매수:당일매도) 해시. 외부 매매·체결로 수량이 변하면 값이 바뀐다. 당일매수/매도 수량까지 넣어 net 무변동 round-trip도 포착. TTL 캐시로 kt00018 폭주 방지. 실패 시 마지막 값(또는 '') 반환 — 폴링이 조용히 재시도.""" now = time.time() with _holdings_seq_lock: if _holdings_seq_cache['seq'] is not None and now < _holdings_seq_cache['expires_at']: return _holdings_seq_cache['seq'] try: import kiwoom_client as kc import hashlib pos = kc.get_positions_all() # {label: [positions]} parts: list[str] = [] for label in sorted(pos): for p in sorted(pos[label], key=lambda x: x.get('code') or ''): parts.append(f"{label}|{p.get('code')}:{p.get('qty')}:{p.get('tdy_buyq')}:{p.get('tdy_sellq')}") seq = hashlib.md5('\n'.join(parts).encode('utf-8')).hexdigest() except Exception: traceback.print_exc() with _holdings_seq_lock: return _holdings_seq_cache['seq'] or '' with _holdings_seq_lock: _holdings_seq_cache['seq'] = seq _holdings_seq_cache['expires_at'] = time.time() + _HOLDINGS_SEQ_TTL return seq # 데이터 레이어 캐시 — 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, chart_ym: str = '') -> 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}:y{chart_ym}' 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, chart_ym=chart_ym) 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_now() 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, chart_ym: str = '') -> 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}:y{chart_ym}' 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, chart_ym=chart_ym) 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, chart_ym=chart_ym) except Exception: traceback.print_exc() _panels_cache.pop(f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}', 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/realtime/quotes': # 실시간 허브 메모리의 최신 현재가/VI (키움 호출 0). 허브 콜드/끊김이면 빈 응답 → JS가 무시. qs = parse_qs(parsed.query) raw = (qs.get('codes') or [''])[0] codes = [c for c in raw.split(',') if c.strip()] quotes, vi, sub_count, connected = {}, {}, 0, False hub = _RT_HUB if hub is not None: import kiwoom_client as kc connected = hub.is_connected() sub_count = len(hub._subs) if not codes: codes = sorted(hub._subs) quotes = hub.get_quotes(codes) for c in codes: v = hub.get_vi(c) if v: vi[kc._clean_code(c)] = v # 체결 신호 — fill_watcher가 체결 시 갱신. 브라우저가 값 변하면 패널 자동 새로고침. fill_seq = '' try: _fp = WORKSPACE / 'state' / 'fill_signal' if _fp.exists(): fill_seq = _fp.read_text().strip() except Exception: pass self._send_json(200, {'connected': connected, 'sub_count': sub_count, 'quotes': quotes, 'vi': vi, 'fill_seq': fill_seq}) return # KPI 라벨 [!] 팝업 본문 — 서버가 HTML 조립, 클라이언트는 innerHTML만. if parsed.path in ('/api/principal', '/api/cashflow'): qs = parse_qs(parsed.query) owner_q = (qs.get('owner') or [''])[0].strip() if not owner_q: self._send_json(400, {'error': 'owner required'}) return render = (_render_principal_body if parsed.path == '/api/principal' else _render_cashflow_body) try: self._send_json(200, {'html': render(owner_q)}) except Exception as e: sys.stderr.write(f'{parsed.path} failed: {e}\n') self._send_json(500, {'error': str(e)}) return if parsed.path == '/api/trades': qs = parse_qs(parsed.query) code = (qs.get('code') or [''])[0].strip() 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] # 매도 실현손익을 '전일 보유 평단' 기준으로 재계산 (ka10170 round-trip 오표시 교정). # rows는 해당 (account,code)의 전체 이력을 포함하므로 재생이 정확하다. rows = tj.rebase_pl(rows) 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 # split_owner=1: 보유를 소유자(본인/가희)별로 분리(같은 종목도 소유자마다 1건). 팝업 종목변경 드롭다운용. # 기본(0): code 전역 dedup — 계산기·주문모달 등 기존 소비자 호환. _sq = parse_qs(parsed.query) split_owner = (_sq.get('split_owner') or ['0'])[0] == '1' seen: set = set() # 관심/감시 dedup (보유 코드 포함) held_owner_seen: set = set() # split_owner 시 (owner, code) 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: continue if split_owner: if (owner, code) in held_owner_seen: continue held_owner_seen.add((owner, code)) elif 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/holdings': # 평단 계산기용 — 소유자(본인/가희)별로 보유종목 합산(일반+ISA 합침, 가중평균 평단). # 같은 종목을 본인·가희 둘 다 보유하면 각각 별도 항목. try: import kiwoom_client as kc agg: dict = {} pos_by_acc = kc.get_positions_all() for label, positions in (pos_by_acc or {}).items(): owner = '가희' if label.startswith('가희_') else '본인' for p in positions: code = p.get('code') qty = int(p.get('qty') or 0) avg = float(p.get('avg_price') or 0) if not code or qty <= 0: continue a = agg.setdefault((owner, code), {'code': code, 'name': p.get('name', ''), 'owner': owner, 'qty': 0, 'cost': 0.0}) a['qty'] += qty a['cost'] += qty * avg items = [] for a in agg.values(): items.append({'code': a['code'], 'name': a['name'], 'owner': a['owner'], 'qty': a['qty'], 'avg': int(round(a['cost'] / a['qty'])) if a['qty'] else 0}) items.sort(key=lambda x: x['qty'] * x['avg'], reverse=True) self._send_json(200, {'items': items, 'count': len(items)}) except Exception as e: traceback.print_exc() self._send_json(500, {'error': f'holdings failed: {e}'}) return if parsed.path == '/api/orders/open': # 4계좌 통합 미체결 주문 — open-orders-modal이 표시 try: import kiwoom_client as kc rows = kc.get_open_orders_all() # 트레일링 예약이면 그 상태를 실어 보낸다 (예약 파일 읽기, 키움 콜 0). for _r in rows: _r['trailing'] = _trailing_info_for(_r.get('ord_no', '')) 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() 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, } # 4계좌 미체결 주문 ka10075 통합 open_orders_count = 0 try: import kiwoom_client as kc rows = kc.get_open_orders_all() open_orders_count = len(rows) except Exception: traceback.print_exc() result['open_orders_count'] = open_orders_count # 진행중 아이템 수 = 미체결 주문 건수(팝업 행과 동일) + 활성 PIN 카드(있으면 1). # 종목코드 중복제거 X — 같은 종목 2계좌 매도도 2건으로 표시(팝업과 일치). result['unique_count'] = open_orders_count + (1 if result.get('active') else 0) 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/holdings_seq': # 외부 매매 감지용 보유 시그니처 (TTL 캐시). 브라우저가 장중 20s 폴링 → 변화 시 패널 1회 갱신. try: self._send_json(200, {'seq': _holdings_seq()}) except Exception as e: traceback.print_exc() self._send_json(500, {'error': f'holdings_seq failed: {e}'}) return if parsed.path == '/api/investor_flow': # 종목별 외국인·기관·개인 일별 순매수 (ka10059, 최신순 20일). 팝업 수급 블록 lazy fetch. 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 now = time.time() with _flow_lock: hit = _flow_cache.get(code) if hit and now < hit[1]: self._send_json(200, {'rows': hit[0]}) return try: import kiwoom_client as kc raw = kc.get_investor_flow(code, days=20) or [] # get_investor_flow 는 천주 단위(unit_tp=1000) → 주 단위로 환산해 반환. rows = [{'date': r.get('date'), 'foreign': (r.get('foreign') or 0) * 1000, 'institution': (r.get('institution') or 0) * 1000, 'individual': (r.get('individual') or 0) * 1000} for r in raw] with _flow_lock: _flow_cache[code] = (rows, time.time() + _FLOW_TTL) self._send_json(200, {'rows': rows}) except Exception as e: traceback.print_exc() self._send_json(500, {'error': f'investor_flow 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 # NXT 거래 가능 여부 먼저 판정 (거래소 선택에 사용). 캐시 없거나 모르면 보수적 True. try: meta = kc.lookup_stock_meta(code) nxt_enable = True if (not meta) else bool(meta.get('nxt_enable', True)) except Exception: nxt_enable = True # NXT 시간대엔 KRX 호가창이 15:30 종가로 고정 → 현재가(NXT)와 갭 발생. # 그 시간대엔 NXT 호가창·NXT 현재가로 정합시킨다. 단 NXT 미상장 종목은 NXT 호가가 # 없으므로 그 시간대에도 KRX 기준으로 표시. _ph = _market_phase_state().get('phase') use_nxt = (_ph == 'nxt') and nxt_enable if use_nxt: book = kc.get_quote_book(code + '_NX') quote = kc.get_stock_quote(code, exchange='NX') else: book = kc.get_quote_book(code) quote = kc.get_stock_quote(code, exchange='KRX') 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', '') # 상한가·하한가 (ka10001 upl_pric/lst_pric) — 사다리 호가 생성 범위 qraw = quote.get('raw') if isinstance(quote.get('raw'), dict) else {} try: book['upper_limit'] = abs(int(str(qraw.get('upl_pric') or '0').strip() or '0')) book['lower_limit'] = abs(int(str(qraw.get('lst_pric') or '0').strip() or '0')) except (ValueError, TypeError): book['upper_limit'] = 0 book['lower_limit'] = 0 # ⚠️ 52주 최고가(250hgst)는 여기 싣지 않는다 — 트레일링 '전고점 기준' # 미리보기용으로 추가했다가 옵션 제거와 함께 제거(2026-07-30). # 기업정보 모달의 '52주 최고'는 /api/stock_info(get_stock_basic) 경로다. book['nxt_enable'] = nxt_enable # 위에서 판정한 값 재사용 # 종목이 속한 시장(KOSPI/KOSDAQ)의 20일 ADR 과매수/과매도 — 극단일 때만 실림. _mkt = (meta or {}).get('market') or '' book['market'] = _mkt _adr_sig = _market_adr_signal(_mkt) if _adr_sig: book['adr'] = _adr_sig 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/holdeval/list': # 보유 평가 계산기 저장 목록 (라이브 시세 없이 저장 필드만; 현재가는 선택 시 quote_book로 조회). try: self._send_json(200, {'items': _load_holdeval_saved()}) except Exception as e: traceback.print_exc() self._send_json(500, {'error': f'holdeval list failed: {e}'}) return if parsed.path == '/api/order/advice': # 거래 모달용: 종목·side 기술적 권장 매수가/매도가 + 근거(이평·ATR). 매도는 avg_price(평단) 선택. qs = parse_qs(parsed.query) code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum()) side = (qs.get('side') or [''])[0].strip().upper() try: avg_price = int((qs.get('avg_price') or ['0'])[0].strip() or '0') or None except ValueError: avg_price = None if not code or side not in ('BUY', 'SELL'): self._send_json(400, {'error': 'code required, side=BUY|SELL'}) return try: advice = _price_advice(code, side, avg_price) except Exception as e: traceback.print_exc() self._send_json(500, {'error': f'order/advice failed: {e}'}) return self._send_json(200, {'code': code, 'side': side, 'advice': advice}) 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', 'STOP_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) # 매수가능 기준은 d2_entra(D+2 정산예수금) — propose 가드(validate_balance_for_buy)와 # 동일. kt00001 ord_alow_amt는 오늘 즉시현금(entr)만 잡혀 매도결제분(D+2)을 빠뜨려 # max_qty가 과소 계산되고 "예수금 부족" 오탐을 냈다. ord_alow_amt는 참고 표시용만 유지. d2 = int(bal.get('d2_entra') or 0) # d2_entra는 미체결 매수 지정가로 묶인 예수금을 아직 포함 → 실제 가용액에서 차감. locked = _pending_buy_lock(account) avail = max(0, d2 - locked) result['ord_alow_amt'] = int(bal.get('ord_alow_amt') or 0) result['d2_entra'] = d2 result['pending_buy_locked'] = locked result['avail_cash'] = avail 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'] = (avail // upper) if upper > 0 else 0 else: result['max_qty'] = (avail // 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: # trde_able_qty가 미체결 매도를 이미 제외하는지 불확실 → min으로 이중차감 방지. # (제외됨: trde_able ≤ 보유−미체결, min=trde_able 유지 / 미제외: min=보유−미체결) pend_sell = _pending_sell_qty(account, code) sellable = max(0, min(pos['trde_able_qty'], pos['qty'] - pend_sell)) result['hold_qty'] = pos['qty'] result['trde_able_qty'] = sellable result['pending_sell_qty'] = pend_sell result['avg_price'] = pos['avg_price'] result['cur_price'] = pos['cur_price'] result['max_qty'] = sellable # 선택 단가 기준 손익 미리보기 — 단가 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, 'pending_sell_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/order/account_status': # 계좌 select 색/보유주수 표시용 — 한 종목에 대해 4계좌 d2_entra·보유주수 일괄 반환. 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 labels = [a['label'] for a in kc.list_accounts()] with ThreadPoolExecutor(max_workers=len(labels) + 2) as ex: pos_f = ex.submit(kc.get_positions_all, exchange='NXT', labels=labels) bal_fs = {lb: ex.submit(kc.get_balance, lb) for lb in labels} pos_by = pos_f.result() bals = {lb: bal_fs[lb].result() for lb in labels} cur_price = 0 accounts = [] for lb in labels: p = next((x for x in (pos_by.get(lb, []) or []) if x['code'] == code), None) if p and p.get('cur_price') and not cur_price: cur_price = p['cur_price'] d2 = int(bals[lb].get('d2_entra') or 0) locked = _pending_buy_lock(lb) # 미체결 매수 묶임 차감 hold = p['qty'] if p else 0 # 매도가능 = min(trde_able, 보유−미체결매도) — check 핸들러와 동일 로직 pend_sell = _pending_sell_qty(lb, code) if p else 0 sellable = max(0, min(p['trde_able_qty'], hold - pend_sell)) if p else 0 accounts.append({ 'label': lb, 'd2_entra': d2, 'avail_cash': max(0, d2 - locked), 'pending_buy_locked': locked, 'hold_qty': hold, 'trde_able_qty': sellable, }) self._send_json(200, {'code': code, 'cur_price': cur_price, 'accounts': accounts}) except Exception as e: traceback.print_exc() self._send_json(500, {'error': f'account_status failed: {e}'}) 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 # _AL(통합) — 정규장(KRX)→NXT(15:30~20:00)가 한 차트에 연속. mins = kc.get_minute_candles(code, tic_scope=tic_scope, today_only=True, exchange='AL') 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), } # 전일종가 — 일봉 캐시에서 오늘 이전 마지막 종가 → 기준선+전일대비 등락률 표시 prev_close = None try: import daily_candles_cache as dcc _today = datetime.now(KST).strftime('%Y%m%d') for b in dcc._select_latest(code, 3): if b.get('date') and str(b['date']) < _today and b.get('close'): prev_close = b['close'] break except Exception: prev_close = None if not prev_close: # 일봉 캐시 미스 → ka10095(현재가−전일대비)로 전일종가 보강. 모든 종목에 전일대비 % 표시. try: _q = kc.get_watchlist_quotes([code]).get(code) or {} _pc = (_q.get('price') or 0) - (_q.get('change') or 0) if _pc > 0: prev_close = _pc except Exception: pass svg = sa.render_svg_chart(snap, range_key='1Y', ref_price=prev_close, intraday=True, hlines=_get_chart_hlines(code)) 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 # 오늘 봉 거래량은 KRX 기준(일봉 히스토리와 동일 기준 → 막대·RVOL·회전율 일관). # 가격 OHLC는 통합(AL) 유지, 거래량만 KRX. krx_vol = 0 try: _qk = kc.get_watchlist_quotes([code], exchange='KRX').get(code) or {} krx_vol = _qk.get('volume', 0) or 0 except Exception: krx_vol = 0 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': krx_vol or 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), 'sma120': sa.sma(closes, 120), } # 마지막 봉이 오늘·정규장 진행형이면 세션경과 비율로 오늘 거래량을 하루치 환산(회전율 '예상') vfrac = None if candles_asc[-1]['date'] == today and _market_phase_state().get('phase') == 'regular': now = datetime.now(KST) hm = now.hour * 60 + now.minute vfrac = min(1.0, max(0.15, (hm - 9 * 60) / 390.0)) # 09:00~15:30 = 390분 # 오늘 회전율·RVOL 값 — 차트 아래 표시 (텍스트 라벨은 차트에 안 그림) _turn, _tscaled = sa.turnover_today(candles_asc, vfrac) _rvol, _rscaled = sa.rvol_today(candles_asc, vfrac) _cap_bits = [] if _rvol is not None: _cap_bits.append(f'RVOL x{_rvol:.1f}') if _turn is not None: _cap_bits.append(f'회전율 {_turn:.2f}%') _turn_cap = ( f'
    {" · ".join(_cap_bits)}{" (예상)" if (_tscaled or _rscaled) else ""}
    ' if _cap_bits else '' ) # 분석 페이지와 동일 패턴 — 1M/3M/6M/1Y 4 개 panel + 탭. 250 봉 캐시 슬라이스만 다름. # 분봉 panel 은 빈 placeholder. 탭 클릭 시 클라이언트가 lazy fetch. hlines = _get_chart_hlines(code) svg_1y = sa.render_svg_chart(snap, range_key='1Y', hlines=hlines) svg_6m = sa.render_svg_chart(snap, range_key='6M', hlines=hlines) svg_3m = sa.render_svg_chart(snap, range_key='3M', hlines=hlines) svg_1m = sa.render_svg_chart(snap, range_key='1M', hlines=hlines) # 분봉 panel 안에 기간 sub-tabs (30분/1h/3h/5h/하루). default = '1h'. # subpanel 은 클라이언트가 sub-toggle 이벤트로 lazy fetch. def _minute_panel(unit_id: str) -> str: return ( f'
    ' '
    ' '' '' '' '' '' '
    ' '
    ' '
    ' ) html_block = ( '
    ' '' '' '' '' '' '' '
    ' + _minute_panel('1m') + _minute_panel('5m') + f'
    {svg_1m}
    ' f'
    {svg_3m}
    ' f'
    {svg_6m}
    ' f'
    {svg_1y}
    ' + _turn_cap + _chart_hlines_ui(code, hlines) ) 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 # chart_ym — 'YYYY'(연도별) 또는 'YYYY-MM'(월별). 형식 위반은 무시(빈값=프리셋). chart_ym = (qs.get('chart_ym') or [''])[0] _ymp = (chart_ym or '').split('-') _ym_ok = ( (len(_ymp) == 1 and len(_ymp[0]) == 4 and _ymp[0].isdigit()) or (len(_ymp) == 2 and len(_ymp[0]) == 4 and _ymp[0].isdigit() and len(_ymp[1]) == 2 and _ymp[1].isdigit()) ) if not _ym_ok: chart_ym = '' 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, chart_ym=chart_ym) 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/ — 종목 분석 상세 페이지 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 stop_raw = (params.get('stop_price') or [''])[0].strip() try: stop_price = int(stop_raw) if stop_raw else None except ValueError: stop_price = None steps_raw = (params.get('trail_steps') or [''])[0].strip() try: trail_steps = json.loads(steps_raw) if steps_raw else None except ValueError: trail_steps = None min_sell_raw = (params.get('min_sell_price') or [''])[0].strip() try: min_sell_price = int(min_sell_raw) if min_sell_raw else None except ValueError: min_sell_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', 'STOP_LIMIT', 'TRAILING_STOP'): self._send_json(400, {'ok': False, 'error': 'order_type must be LIMIT|MARKET|STOP_LIMIT|TRAILING_STOP'}) return if order_type == 'LIMIT' and (price is None or price <= 0): self._send_json(400, {'ok': False, 'error': 'LIMIT requires positive price'}) return if order_type == 'STOP_LIMIT': if side != 'SELL': self._send_json(400, {'ok': False, 'error': '스톱지정가는 매도만 지원'}) return if price is None or price <= 0 or stop_price is None or stop_price <= 0: self._send_json(400, {'ok': False, 'error': '스톱지정가는 단가와 조건단가가 모두 필요'}) return if order_type == 'TRAILING_STOP': # 계단별 조건단가·지정가·주수는 handler 가 현재가·보유수량 기준으로 계산 — # 여기선 계단 정의(하락률·누적 비중)만 받는다. 형식 검증은 handler 가 # orders/trailing.normalize_steps 로 하므로 여기선 최소한만 본다. if side != 'SELL': self._send_json(400, {'ok': False, 'error': '트레일링 스톱은 매도만 지원'}) return if not isinstance(trail_steps, list) or not trail_steps: self._send_json(400, {'ok': False, 'error': '매도 계단을 입력하세요'}) return if min_sell_price is not None and min_sell_price <= 0: self._send_json(400, {'ok': False, 'error': '최저 매도가는 0보다 커야 합니다'}) 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 in ('LIMIT', 'STOP_LIMIT') else None), stop_price=(stop_price if order_type == 'STOP_LIMIT' else None), trail_steps=(trail_steps if order_type == 'TRAILING_STOP' else None), min_sell_price=(min_sell_price if order_type == 'TRAILING_STOP' 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 if res.get('ok'): _bump_panel_signal() # PIN 대기 배지(매수예정/매도예정) 즉시 반영 status = 200 if res.get('ok') else 400 self._send_json(status, res) return if self.path == '/api/order/propose_multi': # 거래 모달 매도 — 같은 소유자 그룹 2계좌 동시 매도. 카드 1장 + PIN 1개가 두 레그 승인. # accounts/qtys 는 콤마 구분 동수 리스트 (예: accounts=일반,ISA & qtys=5,12). side = (params.get('side') or ['SELL'])[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() accounts = [a.strip() for a in (params.get('accounts') or [''])[0].split(',') if a.strip()] try: qtys = [int(q.strip()) for q in (params.get('qtys') or [''])[0].split(',') if q.strip()] except ValueError: qtys = [] price_raw = (params.get('price') or [''])[0].strip() try: price = int(price_raw) if price_raw else None except ValueError: price = None if side != 'SELL' or not symbol or len(accounts) < 2 \ or len(accounts) != len(qtys) or any(q <= 0 for q in qtys): self._send_json(400, {'ok': False, 'error': 'side=SELL + accounts/qtys(2개 이상, 동수) 필요'}) 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 와 동일 정책 — 거부는 web 토스트만, PIN 은 iMessage. res = handler.propose_trade_multi( legs=[{'account': a, 'qty': q} for a, q in zip(accounts, qtys)], side='SELL', symbol=symbol, symbol_name=symbol_name, order_type=order_type, price=(price if order_type == 'LIMIT' else None), ) if res.get('ok'): try: from orders.pin import PinStore as _PinStore _pending = _PinStore().peek() if _pending and not _pending.consumed: handler.send_imessage_pin( pin=_pending.pin, card_id=_pending.card_id, side_label='매도(2계좌)', 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_multi failed: {e}'}) return if res.get('ok'): _bump_panel_signal() # PIN 대기 배지 즉시 반영 self._send_json(200 if res.get('ok') else 400, 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 if res.get('ok'): _bump_panel_signal() # 주문 접수 → 미체결 배지(매수대기/매도대기) 반영 (체결은 fill_watcher가 별도 bump) 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 if res.get('ok'): _bump_panel_signal() # 미체결 취소 → 배지 제거 반영 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 # cancel_active_card 반환은 dict {'ok','message'} — 활성 카드 없어도 베스트에포트라 200. res = handler.cancel_active_card() if res.get('ok'): _bump_panel_signal() # 실제 활성 카드 취소 시만 → PIN 대기 배지 제거 (카드 없이 닫으면 reload 안 함) self._send_json(200, {'ok': True, 'message': res.get('message') or '카드 취소됨'}) 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 == '/interests/groups/set': # 관심종목 그룹 멤버십 편집 — stock(name) 필수. groups 는 체크박스 다중값(반복 필드). # new_group 있으면 먼저 생성 후 포함. if not stock: if wants_json: self._send_json(400, {'ok': False, 'error': '종목명 누락'}) else: self.send_error(400, 'Bad Request', explain='종목명 누락') return group_names = [g.strip() for g in (params.get('groups') or []) if g.strip()] new_group = (params.get('new_group') or [''])[0].strip() try: if new_group: _create_interest_group(new_group) group_names.append(new_group) _set_interest_groups(stock, group_names) _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} groups={group_names} → 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 == '/interests/groups': # 그룹 레지스트리 관리 — op ∈ {create, rename, delete, reorder}. # 관리 모달은 shell 직속이라 panels 리로드로 자동 갱신 안 됨 → 응답에 갱신 목록 동봉. op = (params.get('op') or [''])[0].strip() name = (params.get('name') or [''])[0].strip() new_name = (params.get('new_name') or [''])[0].strip() order_raw = (params.get('order') or [''])[0] try: if op == 'create': _create_interest_group(name) elif op == 'rename': _rename_interest_group(name, new_name) elif op == 'delete': _delete_interest_group(name) elif op == 'reorder': order = [x.strip() for x in order_raw.split(',') if x.strip()] _reorder_interest_groups(order) elif op == 'list': pass # 순수 조회 — 관리 모달 초기 로드용 else: raise ValueError(f'unknown op: {op}') if op != 'list': _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 reg = _load_interest_groups() counts: dict = {} for names in reg.get('members', {}).values(): for n in names: counts[n] = counts.get(n, 0) + 1 sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} op={op} name={name!r} → ok\n') sys.stdout.flush() if wants_json: self._send_json(200, {'ok': True, 'groups': reg.get('groups', []), 'counts': counts}) else: self.send_response(303) self.send_header('Location', '/#tab-interests') 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 if self.path in ('/chart/hlines/add', '/chart/hlines/remove'): # 종목차트 사용자 수평선 추가/제거 — code+price 필수. 응답은 JSON(갱신된 리스트 포함). code = ''.join(ch for ch in (params.get('code') or [''])[0].strip() if ch.isalnum()) price_raw = (params.get('price') or [''])[0].strip() if not code or not price_raw: self._send_json(400, {'ok': False, 'error': 'code/price 필요'}) return try: if self.path.endswith('/add'): _add_chart_hline(code, price_raw) else: _remove_chart_hline(code, price_raw) except ValueError as e: self._send_json(400, {'ok': False, 'error': str(e)}) return except Exception as e: traceback.print_exc() self._send_json(500, {'ok': False, 'error': str(e)}) return sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} code={code} price={price_raw} → ok\n') sys.stdout.flush() self._send_json(200, {'ok': True, 'lines': _get_chart_hlines(code)}) return if self.path == '/holdeval/save': # 보유 평가 계산기 저장 — owner/code/name/avg/qty. code·avg·qty 필수. owner = (params.get('owner') or [''])[0].strip() code = (params.get('code') or [''])[0].strip() name = (params.get('name') or [''])[0].strip() try: avg = float((params.get('avg') or ['0'])[0].strip() or '0') qty = float((params.get('qty') or ['0'])[0].strip() or '0') except ValueError: avg = qty = 0 try: items = _add_holdeval_saved(owner, code, name, avg, qty) self._send_json(200, {'ok': True, 'items': items}) except ValueError as e: self._send_json(400, {'ok': False, 'error': str(e)}) except Exception as e: traceback.print_exc() self._send_json(500, {'ok': False, 'error': str(e)}) return if self.path == '/holdeval/update': # 저장 항목 수정 — id 필수, owner/avg/qty 갱신. code/name은 있으면 교체. entry_id = (params.get('id') or [''])[0].strip() owner = (params.get('owner') or [''])[0].strip() code = (params.get('code') or [''])[0].strip() name = (params.get('name') or [''])[0].strip() try: avg = float((params.get('avg') or ['0'])[0].strip() or '0') qty = float((params.get('qty') or ['0'])[0].strip() or '0') except ValueError: avg = qty = 0 if not entry_id: self._send_json(400, {'ok': False, 'error': 'id required'}) return try: items = _update_holdeval_saved(entry_id, owner, avg, qty, code, name) self._send_json(200, {'ok': True, 'items': items}) except ValueError as e: self._send_json(400, {'ok': False, 'error': str(e)}) except Exception as e: traceback.print_exc() self._send_json(500, {'ok': False, 'error': str(e)}) return if self.path == '/holdeval/delete': entry_id = (params.get('id') or [''])[0].strip() if not entry_id: self._send_json(400, {'ok': False, 'error': 'id required'}) return try: items = _delete_holdeval_saved(entry_id) self._send_json(200, {'ok': True, 'items': items}) except Exception as e: traceback.print_exc() self._send_json(500, {'ok': False, 'error': str(e)}) return if self.path == '/notes/set': # 종목 자유 메모 — stock(name) 또는 code 둘 중 하나는 필수. # note 가 빈 텍스트면 항목 제거. code = (params.get('code') or [''])[0].strip() note_raw = (params.get('note') or [''])[0] 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_note(code or None, stock or None, note_raw) _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} note_len={len(note_raw.strip())} → 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//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//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) # ---------------- 실시간 시세 허브 (realtime_hub 연동) ---------------- # 키움 WebSocket 실시간 현재가(0B)·VI(1h)를 메모리에 들고 화면에 라이브로 표시. # kill-switch: 환경변수 BEHIVE_REALTIME=0 이면 비활성(REST 폴백만). 기본 활성. # hub 시작·연결 실패가 페이지 서빙을 막지 않도록 전부 fail-safe. _RT_HUB = None _RT_MAX_CODES = 200 # 폭주 방지 상한. 키움은 59종목(=118등록) 수락 확인. 초과분은 우선순위 컷+로깅. def _rt_enabled() -> bool: import os return os.environ.get('BEHIVE_REALTIME', '1') not in ('0', 'false', 'no') def _rt_gather_codes() -> list: """구독 대상: 보유(4계좌) > 관심 > 감시 우선순위. 상한 초과분은 잘라내고 로깅.""" import kiwoom_client as kc seen: set = set() codes: list = [] def _add(code): if not code: return cc = kc._clean_code(code) if cc and cc not in seen: seen.add(cc) codes.append(cc) try: for _label, positions in (kc.get_positions_all() or {}).items(): for p in positions: _add(p.get('code')) except Exception: traceback.print_exc() try: for c in _load_interests(): _add(c.get('code')) except Exception: pass try: for c in _load_watchlist(): _add(c.get('code')) except Exception: pass if len(codes) > _RT_MAX_CODES: dropped = codes[_RT_MAX_CODES:] sys.stderr.write(f'[realtime] 구독 상한 {_RT_MAX_CODES} 초과 → {len(dropped)}종목 제외: {dropped}\n') codes = codes[:_RT_MAX_CODES] return codes def _rt_resubscribe_loop(): while True: try: if _RT_HUB is not None: codes = _rt_gather_codes() if codes: _RT_HUB.set_subscriptions(codes) except Exception: traceback.print_exc() time.sleep(60) def _rt_start(): """실시간 허브 기동 (fail-safe). 실패해도 서버는 정상 서빙(REST 폴백).""" global _RT_HUB if not _rt_enabled(): print('[realtime] BEHIVE_REALTIME=0 → 실시간 허브 비활성 (REST 폴백)', flush=True) return try: import realtime_hub as rh _RT_HUB = rh.RealtimeHub() codes = _rt_gather_codes() _RT_HUB.set_subscriptions(codes) _RT_HUB.start() threading.Thread(target=_rt_resubscribe_loop, name='realtime-resub', daemon=True).start() print(f'[realtime] 허브 기동 — {len(codes)}종목 구독', flush=True) except Exception: traceback.print_exc() _RT_HUB = None print('[realtime] 허브 기동 실패 → REST 폴백으로 계속', flush=True) def cmd_serve() -> int: _rt_start() 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:]))