#!/usr/bin/env python3 """비하이브 워치리스트 실시간 웹 뷰. 페이지 열기·새로고침 시점에 키움 ka10095 batch 호출. GET / → watchlist 카드형 HTML 한 페이지로 응답. JS·외부 리소스 없음 (인라인 CSS만). GET /api/panels[?owner=self|gahee] → 패널 JSON (자동 갱신·partial swap용). POST /{delete,restore,purge} → 감시종목 lifecycle (watchlist). POST /interests/{add,update,delete} → 관심종목 수동 관리. JSON 응답 (Accept: application/json). 서버 바인드는 Tailscale IP(`100.75.148.12`)로 제한 — 외부망 직접 도달 차단. 외부 노출은 NAS reverse proxy(`stock.hyowons.net` → mac:18790) 경유. write 엔드포인트는 인증·CSRF 가드 없음 — Tailnet 내부망 한정 운영을 전제. CLI: python3 behive_web.py serve # 서버 기동 (launchd가 호출) python3 behive_web.py render [--out FILE] # HTML 한 번 렌더 후 출력 (디버깅용) """ from __future__ import annotations import argparse import gzip import html import json import sys import threading import time import traceback from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from zoneinfo import ZoneInfo WORKSPACE = Path(__file__).resolve().parent.parent WATCHLIST = WORKSPACE / 'state' / 'behive_watchlist.json' INTERESTS = WORKSPACE / 'state' / 'behive_interests.json' STOCK_TAGS = WORKSPACE / 'state' / 'behive_stock_tags.json' ALERTS_STATE = WORKSPACE / 'state' / 'watchlist_alerts.json' HOLIDAYS_FILE = WORKSPACE / 'state' / 'market_holidays.json' SNAPSHOT_FILE = WORKSPACE / 'state' / 'portfolio_daily_snapshot.json' TRADE_JOURNAL_FILE = WORKSPACE / 'state' / 'trade_journal.jsonl' MARKET_HISTORY_FILE = WORKSPACE / 'state' / 'market_indicators_history.jsonl' ADR_TREND_DAYS = 30 # 시장정보 sub-tab ADR 추세 차트 default 기간 (영업일 기준 최근 N행) # (label, days) — days=-1 전체. UI 버튼·서버 sanity check·localStorage 허용값 모두 단일 진실 출처. ADR_TREND_PRESETS = [('1주', 7), ('1달', 30), ('3달', 90), ('6달', 180), ('1년', 365), ('전체', -1)] ADR_MA_WINDOW = 20 # ADR 이동평균 윈도우. 카드 수치·차트 모두 이 값 사용. 가용 데이터 < window 면 평균 산출 안 함 NET_WORTH_CHART_DAYS = -2 # default = 이번달. 클라이언트가 ?chart_days로 override # (label, days) — 클라이언트 UI 옵션. days=-1 전체, -2 이번달(KST 기준), -3 이번주(이번 ISO 월요일~오늘). NET_WORTH_CHART_PRESETS = [('전체보기', -1), ('올해', -4), ('이번달', -2), ('이번주', -3)] # 차트 단위 토글 — 각 구간 마지막 영업일 net_worth만 표시. 'day'=기본 일별. NET_WORTH_UNIT_PRESETS = [('일별', 'day'), ('주별', 'week'), ('월별', 'month'), ('연별', 'year')] NET_WORTH_CHART_UNIT = 'day' # 단위 → tooltip 손익 라벨 매핑 _NET_WORTH_DELTA_LABEL = {'day': '그날 손익', 'week': '주간 손익', 'month': '월간 손익', 'year': '연간 손익'} # 기간별 허용 단위 — 의미없는 조합(이번주+월별 등) 비활성용. 기간이 단위 너비보다 좁으면 비허용. NET_WORTH_ALLOWED_UNITS_BY_DAYS: dict[int, tuple[str, ...]] = { -3: ('day',), # 이번주 — day 만 (주별은 한 점이라 의미 없음) -2: ('day', 'week'), # 이번달 — month 빼고 (월별은 한 점이라 의미 없음) -4: ('day', 'week', 'month'), # 올해 — year 빼고 (연별은 한 점이라 의미 없음) -1: ('day', 'week', 'month', 'year'), # 전체 — 모두 } # 차트 표시 모드 — 'pnl'=시작점 baseline=0 누적손익(기본), 'net'=순자산 시계열. # 'pnl'=시작점 baseline=0 누적손익(전부 누적), 'period'=각 점이 그 기간(일/주/월/연) 자체 손익, 'net'=순자산 시계열. NET_WORTH_MODE_PRESETS = [('순자산', 'net'), ('손익누적', 'pnl'), ('기간손익', 'period')] NET_WORTH_CHART_MODE = 'pnl' KST = ZoneInfo('Asia/Seoul') BIND_HOST = '' # 모든 인터페이스 (Tailscale + LAN). 외부망 직접 차단은 공유기 NAT가 담당. BIND_PORT = 18790 QUOTE_WORKERS = 8 QUOTE_CACHE_TTL = 30.0 # 첫 cold 8s 동안 분산 박힌 캐시가 다음 RENDER 만료(10s) 시점에 살아있도록. 가격 stale 최대 30s. sys.path.insert(0, str(WORKSPACE / 'scripts')) _holidays_cache: dict = {'mtime': 0.0, 'set': set()} _traded_codes_cache: dict = {'mtime': 0.0, 'set': set()} _quote_cache: dict[str, tuple[dict, float]] = {} _quote_cache_lock = threading.Lock() INDICES_CACHE_TTL = 30.0 _indices_cache: dict = {'data': None, 'expires_at': 0.0} _indices_lock = threading.Lock() _indices_refresh_in_flight = False # ticker 회전 순서: 국내 → 미국 → 환율/원자재. ex.map으로 입력 순서 보존. _INDEX_SOURCES = ( ('naver_kr', 'KOSPI', 'KOSPI', None), ('naver_kr', 'KOSDAQ', 'KOSDAQ', None), ('naver_gl', '.INX', 'S&P500', None), ('naver_gl', '.IXIC', 'NASDAQ', None), ('naver_gl', '.N225', '닛케이225', None), ('yahoo', 'KRW=X', 'USD/KRW', 2), ('yahoo', 'CL=F', 'WTI', 2), ('yahoo', 'GC=F', '금', 2), ('yahoo', 'SI=F', '은', 2), ('yahoo', 'HG=F', '동', 3), ) def _fmt_quote(label: str, price: float, change: float, pct: float, decimals: int = 2) -> dict: direction = 'up' if change > 0 else ('down' if change < 0 else 'flat') return { 'kind': 'index', 'label': label, 'price': f'{price:,.{decimals}f}', 'change': f'{abs(change):,.{decimals}f}', 'pct': f'{abs(pct):.2f}', 'direction': direction, } def _fetch_naver_index(code: str, label: str, base: str) -> dict | None: """네이버 stock API로 지수 1개 fetch. base는 국내(`m.stock.naver.com/api`) 또는 해외(`api.stock.naver.com`).""" import urllib.request try: url = f'{base}/index/{code}/basic' req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) with urllib.request.urlopen(req, timeout=3.0) as resp: raw = resp.read() data = json.loads(raw.decode('utf-8', 'ignore')) price_s = str(data.get('closePrice') or '').replace(',', '') change_s = str(data.get('compareToPreviousClosePrice') or '').replace(',', '') pct_s = str(data.get('fluctuationsRatio') or '').replace(',', '') if not price_s: return None return _fmt_quote(label, float(price_s), float(change_s or 0), float(pct_s or 0), 2) except Exception as e: sys.stderr.write(f'naver index fetch {code} failed: {e}\n') return None def _fetch_yahoo_quote(symbol: str, label: str, decimals: int = 2) -> dict | None: """Yahoo Finance chart API로 종목·환율·원자재 1개 fetch. 마지막 두 close 비교로 등락.""" import urllib.request try: url = f'https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range=2d&interval=1d' req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) with urllib.request.urlopen(req, timeout=3.0) as resp: raw = resp.read() data = json.loads(raw.decode('utf-8', 'ignore')) closes = [x for x in data['chart']['result'][0]['indicators']['quote'][0]['close'] if x is not None] if not closes: return None cur = closes[-1] prev = closes[-2] if len(closes) >= 2 else cur change = cur - prev pct = (change / prev * 100) if prev else 0.0 return _fmt_quote(label, cur, change, pct, decimals) except Exception as e: sys.stderr.write(f'yahoo fetch {symbol} failed: {e}\n') return None def _fetch_one_index(spec) -> dict | None: kind, code, label, decimals = spec if kind == 'naver_kr': return _fetch_naver_index(code, label, 'https://m.stock.naver.com/api') if kind == 'naver_gl': return _fetch_naver_index(code, label, 'https://api.stock.naver.com') if kind == 'yahoo': return _fetch_yahoo_quote(code, label, decimals) return None def _fetch_indices_now() -> list[dict]: """10개 source 병렬 fetch (ex.map으로 순서 보존). 각 source는 3s timeout.""" with ThreadPoolExecutor(max_workers=len(_INDEX_SOURCES)) as ex: results = list(ex.map(_fetch_one_index, _INDEX_SOURCES)) return [r for r in results if r] def _refresh_indices_async() -> None: """SWR 백그라운드 갱신. single-flight 가드로 다중 fetch 차단.""" global _indices_refresh_in_flight with _indices_lock: if _indices_refresh_in_flight: return _indices_refresh_in_flight = True def _run(): global _indices_refresh_in_flight try: data = _fetch_indices_now() if data: with _indices_lock: _indices_cache['data'] = data _indices_cache['expires_at'] = time.time() + INDICES_CACHE_TTL except Exception as e: sys.stderr.write(f'indices background refresh failed: {e}\n') finally: with _indices_lock: _indices_refresh_in_flight = False threading.Thread(target=_run, daemon=True, name='indices-swr').start() def _fetch_indices() -> list[dict]: """ticker 회전용 시장 지표 — KOSPI/KOSDAQ + 미국 3대 지수 + USD/KRW + WTI/금. TTL 30s + stale-while-revalidate: 캐시 있으면 즉시 반환(만료여도 stale 반환 + 백그라운드 갱신). 캐시 비었을 때만 동기 병렬 fetch. 첫 cold ~300ms, 이후 사용자는 indices 대기 없음.""" now = time.time() with _indices_lock: cached = _indices_cache['data'] fresh = cached is not None and _indices_cache['expires_at'] > now if cached: if not fresh: _refresh_indices_async() return cached data = _fetch_indices_now() if data: with _indices_lock: _indices_cache['data'] = data _indices_cache['expires_at'] = now + INDICES_CACHE_TTL return data return [] # 시장 상황 카드 — ADR(상승/하락 종목수)과 투자자별 매매(개인/외국인/기관) 데이터. # 네이버 m.stock 비공식 API 한 콜로 KOSPI/KOSDAQ 둘 다 fetch. # 분당 변화 폭이 작아 5분 TTL + stale-while-revalidate. MARKET_INDICATORS_TTL = 300.0 _market_indicators_cache: dict = {'data': None, 'expires_at': 0.0} _market_indicators_lock = threading.Lock() _market_indicators_refresh_in_flight = False _MARKET_INDICATOR_SYMBOLS = (('KOSPI', '코스피'), ('KOSDAQ', '코스닥')) def _parse_signed_int(s) -> int | None: """네이버 응답은 '+13,930' / '-25,461' / '0' 형태. 콤마·부호 제거 후 정수.""" if s is None: return None try: return int(str(s).replace(',', '').replace('+', '').strip()) except (ValueError, TypeError): return None def _parse_count(s) -> int: """upDownStockInfo 값은 '4' / '1,450' / '839' — 콤마 포함 가능. 실패 시 0.""" if s is None: return 0 try: return int(str(s).replace(',', '').strip()) except (ValueError, TypeError): return 0 def _fetch_one_market_indicator(symbol_label: tuple[str, str]) -> dict | None: """네이버 m.stock integration API 한 콜로 시장 1개의 dealTrendInfo + upDownStockInfo 수집.""" import urllib.request symbol, label = symbol_label try: url = f'https://m.stock.naver.com/api/index/{symbol}/integration' req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) with urllib.request.urlopen(req, timeout=3.0) as resp: raw = resp.read() data = json.loads(raw.decode('utf-8', 'ignore')) except Exception as e: sys.stderr.write(f'market indicator fetch {symbol} failed: {e}\n') return None up_down = data.get('upDownStockInfo') or {} rise = _parse_count(up_down.get('riseCount')) upper = _parse_count(up_down.get('upperCount')) fall = _parse_count(up_down.get('fallCount')) lower = _parse_count(up_down.get('lowerCount')) steady = _parse_count(up_down.get('steadyCount')) adv = rise + upper dec = fall + lower adr = (adv / dec * 100.0) if dec else None deal = data.get('dealTrendInfo') or {} personal = _parse_signed_int(deal.get('personalValue')) foreign = _parse_signed_int(deal.get('foreignValue')) institutional = _parse_signed_int(deal.get('institutionalValue')) return { 'symbol': symbol, 'label': label, 'rise': rise, 'upper': upper, 'fall': fall, 'lower': lower, 'steady': steady, 'adr': adr, 'bizdate': str(deal.get('bizdate') or '').strip() or None, 'personal': personal, 'foreign': foreign, 'institutional': institutional, } def _fetch_market_indicators_now() -> list[dict]: with ThreadPoolExecutor(max_workers=len(_MARKET_INDICATOR_SYMBOLS)) as ex: results = list(ex.map(_fetch_one_market_indicator, _MARKET_INDICATOR_SYMBOLS)) return [r for r in results if r] # 분기 영업이익 캐시 — (value_eok, period, fetched_at). 분기 데이터는 자주 안 변해 6h TTL. _QUARTER_OP_CACHE: dict[str, tuple] = {} _QUARTER_OP_TTL = 6 * 3600 def _fetch_quarter_op_profit(code: str) -> dict | None: """네이버 m.stock 분기 재무에서 '최근 확정 분기' 영업이익(억원) + 분기 라벨 반환. 네이버는 응답에 미래 분기 컨센서스(증권사 추정)를 isConsensus='Y'로 섞어 준다. 그 추정치만 제외하고, 확정(N) 분기 중 가장 최근 것을 채택한다. (값 자체에 대한 임계 가드는 두지 않는다 — 네이버의 확정 분류를 신뢰. 호황기 급증 실적을 오탐 차단하던 과거 가드를 제거함.) 반환: {'value': 억원(int), 'period': 'YYYY.MM'} 또는 None. """ import time as _t now = _t.time() hit = _QUARTER_OP_CACHE.get(code) if hit and (now - hit[2]) < _QUARTER_OP_TTL: return {'value': hit[0], 'period': hit[1]} if hit[0] is not None else None import urllib.request try: url = f'https://m.stock.naver.com/api/stock/{code}/finance/quarter' req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) with urllib.request.urlopen(req, timeout=3.0) as resp: data = json.loads(resp.read().decode('utf-8', 'ignore')) fi = data.get('financeInfo') or {} titles = fi.get('trTitleList') or [] op_cols = {} for r in (fi.get('rowList') or []): if r.get('title') == '영업이익': op_cols = r.get('columns') or {} break # 컨센서스(추정) 제외, 확정 분기 key 내림차순(최근 우선) cand = [t for t in titles if t.get('isConsensus') != 'Y' and t.get('key')] cand.sort(key=lambda t: t['key'], reverse=True) chosen = None for t in cand: raw = (op_cols.get(t['key']) or {}).get('value') if raw in (None, '', '-'): continue try: val = int(str(raw).replace(',', '')) except ValueError: continue chosen = {'value': val, 'period': (t.get('title') or '').rstrip('.')} break _QUARTER_OP_CACHE[code] = (chosen['value'] if chosen else None, chosen['period'] if chosen else None, now) return chosen except Exception as e: sys.stderr.write(f'quarter op_profit fetch {code} failed: {e}\n') return None def _refresh_market_indicators_async() -> None: global _market_indicators_refresh_in_flight with _market_indicators_lock: if _market_indicators_refresh_in_flight: return _market_indicators_refresh_in_flight = True def _run(): global _market_indicators_refresh_in_flight try: data = _fetch_market_indicators_now() if data: with _market_indicators_lock: _market_indicators_cache['data'] = data _market_indicators_cache['expires_at'] = time.time() + MARKET_INDICATORS_TTL except Exception as e: sys.stderr.write(f'market indicators background refresh failed: {e}\n') finally: with _market_indicators_lock: _market_indicators_refresh_in_flight = False threading.Thread(target=_run, daemon=True, name='market-indicators-swr').start() def _fetch_market_indicators() -> list[dict]: """KOSPI/KOSDAQ의 ADR·투자자별 매매. 5분 TTL + stale-while-revalidate. 실패시 빈 리스트 — caller(_render_market_panel)가 graceful degrade 처리.""" now = time.time() with _market_indicators_lock: cached = _market_indicators_cache['data'] fresh = cached is not None and _market_indicators_cache['expires_at'] > now if cached: if not fresh: _refresh_market_indicators_async() return cached data = _fetch_market_indicators_now() if data: with _market_indicators_lock: _market_indicators_cache['data'] = data _market_indicators_cache['expires_at'] = now + MARKET_INDICATORS_TTL return data return [] def _load_holidays() -> set[str]: """state/market_holidays.json에서 KRX 휴장일 set(YYYY-MM-DD) 로드. 파일 없거나 깨지면 빈 set. 파일 mtime 기반 lazy reload — holiday_sync가 갱신하면 다음 호출에서 자동 반영.""" if not HOLIDAYS_FILE.exists(): return set() try: mtime = HOLIDAYS_FILE.stat().st_mtime except OSError: return _holidays_cache.get('set', set()) if mtime != _holidays_cache.get('mtime'): try: data = json.loads(HOLIDAYS_FILE.read_text()) _holidays_cache['set'] = set((data.get('holidays') or {}).keys()) _holidays_cache['mtime'] = mtime except Exception as e: sys.stderr.write(f'holidays load failed: {e}\n') return _holidays_cache.get('set', set()) def _load_traded_codes() -> set[str]: """trade_journal.jsonl 에서 distinct 종목코드 set 로드. mtime 기반 lazy reload. 워치리스트·관심종목 행에서 '거래내역' 버튼 노출 여부 판정용.""" if not TRADE_JOURNAL_FILE.exists(): return set() try: mtime = TRADE_JOURNAL_FILE.stat().st_mtime except OSError: return _traded_codes_cache.get('set', set()) if mtime != _traded_codes_cache.get('mtime'): codes: set[str] = set() try: for ln in TRADE_JOURNAL_FILE.read_text().splitlines(): ln = ln.strip() if not ln: continue try: d = json.loads(ln) c = d.get('code') if c: codes.add(c) except json.JSONDecodeError: continue _traded_codes_cache['set'] = codes _traded_codes_cache['mtime'] = mtime except Exception as e: sys.stderr.write(f'trade codes load failed: {e}\n') return _traded_codes_cache.get('set', set()) _US_EASTERN = ZoneInfo('America/New_York') def _us_market_open() -> bool: """미국 야간/새벽 sentiment 블록 표시 기간 — KST 22:30 ~ 다음날 10:00. 미국 정규장(ET 09:30~16:00 = KST 22:30~05:00 DST) 시간 + 미국장 마감 후 한국 정규장 개장 1시간까지. 한국 정규장 시작 후 10:00 이전엔 야간 미국 마감 데이터가 여전히 sentiment 참조용으로 유효.""" now_kst = datetime.now(KST) hm = now_kst.hour * 60 + now_kst.minute return hm >= 22 * 60 + 30 or hm < 10 * 60 # 미국 장 시간에 시장 카드 KOSPI/KOSDAQ 자리를 채울 KOSPI 200 + MSCI Korea(EWY) quote 캐시. # 5분 TTL. _fetch_yahoo_quote 재사용. 한국 정규장 시간엔 fetch X (캐시도 만료 → 빈 결과). _EXTRA_MARKET_QUOTES_TTL = 300.0 _extra_market_quotes_cache: dict = {'data': None, 'expires_at': 0.0} _extra_market_quotes_lock = threading.Lock() # 지수 차트 sparkline 캐시 — 모달의 lazy fetch. 5분 TTL. label → Yahoo symbol 매핑은 _IDX_CHART_SYMBOL. _IDX_CHART_SYMBOL = { 'KOSPI': '^KS11', 'KOSDAQ': '^KQ11', 'S&P500': '^GSPC', '필라델피아 반도체': '^SOX', 'MSCI Korea (EWY)': 'EWY', 'VIX (공포지수)': '^VIX', '미국채 10년 (%)': '^TNX', } _IDX_CHART_TTL = 300.0 _idx_chart_cache: dict = {} # symbol -> {'data': list[float], 'expires_at': float} _idx_chart_lock = threading.Lock() def _fetch_idx_chart_series(symbol: str) -> list[dict] | None: """Yahoo chart API 로 최근 1개월 일별 close 시리즈. 5분 TTL per-symbol. 각 점은 {'d': iso_date, 'v': close}.""" now = time.time() with _idx_chart_lock: c = _idx_chart_cache.get(symbol) if c and c.get('expires_at', 0) > now: return c['data'] import urllib.request try: url = f'https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range=1mo&interval=1d' req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) with urllib.request.urlopen(req, timeout=4.0) as resp: data = json.loads(resp.read().decode('utf-8', 'ignore')) result = data.get('chart', {}).get('result') or [] if not result: return None timestamps = result[0].get('timestamp') or [] closes = result[0]['indicators']['quote'][0].get('close') or [] series: list[dict] = [] for ts, c in zip(timestamps, closes): if c is None: continue try: d_iso = datetime.fromtimestamp(int(ts), tz=KST).strftime('%Y-%m-%d') except Exception: continue series.append({'d': d_iso, 'v': float(c)}) if len(series) < 2: return None with _idx_chart_lock: _idx_chart_cache[symbol] = {'data': series, 'expires_at': now + _IDX_CHART_TTL} return series except Exception as e: sys.stderr.write(f'idx chart fetch {symbol} failed: {e}\n') return None def _fetch_extra_market_quotes() -> list[dict] | None: """미국 장 시간 전용: KOSPI 200 + MSCI Korea(EWY) 두 quote. 5분 캐시. fetch 실패하거나 부분 실패면 빈 quote 자리 None 으로. 모두 실패면 None 리턴.""" now = time.time() with _extra_market_quotes_lock: cached = _extra_market_quotes_cache['data'] fresh = cached is not None and _extra_market_quotes_cache['expires_at'] > now if cached and fresh: return cached results = [] for sym, lbl, dec in ( ('EWY', 'MSCI Korea (EWY)', 2), ('^SOX', '필라델피아 반도체', 2), ('^VIX', 'VIX (공포지수)', 2), ('^TNX', '미국채 10년 (%)', 3), ): q = _fetch_yahoo_quote(sym, lbl, dec) results.append(q) if not any(results): return None with _extra_market_quotes_lock: _extra_market_quotes_cache['data'] = results _extra_market_quotes_cache['expires_at'] = now + _EXTRA_MARKET_QUOTES_TTL return results def _market_active_now() -> bool: """NXT 운영 시간대(평일 08:00~20:00 KST) 안 + KRX 휴장일 아님. 이 조건 밖에선 자동 갱신 토글이 비활성화된다. 휴장일 데이터는 holiday_sync가 주 1회 investing.com에서 갱신.""" now = datetime.now(KST) if now.weekday() >= 5: # 토(5)·일(6) return False if now.hour < 8 or now.hour >= 20: return False if now.strftime('%Y-%m-%d') in _load_holidays(): return False return True def _show_day_change_now() -> bool: """종목 등락률 표시 시간대 — 평일 08:00 ~ 다음날 06:00 (KST). NXT 마감(20:00) 이후에도 다음날 새벽 06:00까지 직전 거래일 등락률을 계속 표시. 주말·휴장일은 표시 X (단, 직전 거래일이 평일 거래일이면 그 새벽 시간대까지는 ON).""" now = datetime.now(KST) today_str = now.strftime('%Y-%m-%d') holidays = _load_holidays() wd = now.weekday() hour = now.hour # 평일 08:00 이후 — 오늘 거래일 진행 (휴장 아닐 때) if wd < 5 and hour >= 8 and today_str not in holidays: return True # 평일 또는 토요일 00:00~06:00 — 직전 거래일이 평일·비휴장이면 그 결과 표시 if hour < 6 and wd <= 5: prev = now - timedelta(days=1) if prev.weekday() < 5 and prev.strftime('%Y-%m-%d') not in holidays: return True return False def _market_phase_state() -> dict: """현재 시각 기준 시장 phase 판정. 거래 모달 시간 가드용. - regular(정규장): 평일 09:00~15:30 — KRX+NXT 거래 가능 - nxt(NXT): 평일 08:00~09:00 + 15:30~20:00 — NXT만 거래 가능 - closed(장외): 평일 그 외 — 매매 불가 - holiday(휴장): KRX 휴장일 — 매매 불가 - weekend(주말): 토·일 — 매매 불가 """ now = datetime.now(KST) time_str = now.strftime('%H:%M') if now.weekday() >= 5: return {'phase': 'weekend', 'label': '주말 휴장', 'active': False, 'time': time_str} if now.strftime('%Y-%m-%d') in _load_holidays(): return {'phase': 'holiday', 'label': '휴장일', 'active': False, 'time': time_str} hm = now.hour * 60 + now.minute if 9 * 60 <= hm < 15 * 60 + 30: return {'phase': 'regular', 'label': '정규장', 'active': True, 'time': time_str} if 8 * 60 <= hm < 20 * 60: return {'phase': 'nxt', 'label': 'NXT 시장', 'active': True, 'time': time_str} return {'phase': 'closed', 'label': '장외 시간', 'active': False, 'time': time_str} def _load_watchlist() -> list[dict]: if not WATCHLIST.exists(): return [] data = json.loads(WATCHLIST.read_text()) return sorted(data.values(), key=lambda e: e.get('saved_at', ''), reverse=True) def _load_interests() -> list[dict]: if not INTERESTS.exists(): return [] data = json.loads(INTERESTS.read_text()) return sorted(data.values(), key=lambda e: e.get('saved_at', ''), reverse=True) def _fetch_quotes_batch(entries: list[dict]) -> list[dict]: """ka10095(관심종목정보) 한 콜로 워치리스트 시세 일괄 조회. 19종목 ~0.05s. 캐시 히트인 entry는 캐시 사용, 미스인 entry만 batch에 포함. batch 실패·응답 누락 종목은 _fetch_quote 단건(ka10001) fallback.""" import kiwoom_client as kc now = time.time() cards: list[dict] = [] miss_codes: list[str] = [] miss_indices: list[int] = [] for e in entries: out = dict(e) code = (e.get('code') or '').strip() name = e.get('stock', '') token = code or name out['quote_error'] = None out['price'] = None out['day_change'] = None out['day_change_pct'] = None out['open'] = None out['high'] = None out['low'] = None cards.append(out) if not token: out['quote_error'] = '종목 식별 실패' continue with _quote_cache_lock: cached = _quote_cache.get(token) if cached and cached[1] > now: snap = cached[0] out['price'] = snap['price'] out['day_change'] = snap['day_change'] out['day_change_pct'] = snap['day_change_pct'] out['quote_error'] = snap['quote_error'] out['open'] = snap.get('open') out['high'] = snap.get('high') out['low'] = snap.get('low') out['_krx_price'] = snap.get('_krx_price', 0) out['_nxt_price'] = snap.get('_nxt_price', 0) continue if code: miss_codes.append(code) miss_indices.append(len(cards) - 1) else: # 코드 없는 entry는 resolve 거쳐야 해서 단건 호출 — 거의 없음. cards[-1] = _fetch_quote(e) if not miss_codes: return cards try: batch = kc.get_watchlist_quotes(miss_codes) except Exception as ex: sys.stderr.write(f'ka10095 batch 실패, ka10001 fallback: {ex}\n') for idx in miss_indices: cards[idx] = _fetch_quote(entries[idx]) return cards # NXT 비교용 batch — 마크(KRX/NXT) 판정에 사용. 실패해도 fallback X (마크만 KRX로 처리). nxt_batch: dict[str, dict] = {} try: nxt_batch = kc.get_watchlist_quotes(miss_codes, exchange='NXT') except Exception as ex: sys.stderr.write(f'ka10095 NXT batch 실패 (마크 판정용): {ex}\n') for idx, code in zip(miss_indices, miss_codes): q = batch.get(code) out = cards[idx] if not q: cards[idx] = _fetch_quote(entries[idx]) continue price = q.get('price') or 0 if price: out['price'] = price out['day_change'] = q.get('change') out['day_change_pct'] = q.get('change_pct') out['open'] = q.get('open') or None out['high'] = q.get('high') or None out['low'] = q.get('low') or None else: out['quote_error'] = '가격 0' # 마크 판정: NXT batch 가격 vs 기본 batch 가격 비교. nxt_q = nxt_batch.get(code) if nxt_batch else None nxt_price = (nxt_q or {}).get('price', 0) if nxt_q else 0 out['_krx_price'] = price out['_nxt_price'] = nxt_price snap = { 'price': out['price'], 'day_change': out['day_change'], 'day_change_pct': out['day_change_pct'], 'quote_error': out['quote_error'], 'open': out['open'], 'high': out['high'], 'low': out['low'], '_krx_price': price, '_nxt_price': nxt_price, } with _quote_cache_lock: _quote_cache[code] = (snap, time.time() + QUOTE_CACHE_TTL) return cards def _fetch_quote(entry: dict) -> dict: """키움 ka10001 — 단일 종목 현재가 fallback. 평소 _fetch_quotes_batch가 처리. 종목별 QUOTE_CACHE_TTL 캐시로 batch 실패 시에도 rate limit 완화.""" import kiwoom_client as kc out = dict(entry) code = (entry.get('code') or '').strip() name = entry.get('stock', '') target_token = code or name out['quote_error'] = None out['price'] = None out['day_change'] = None out['day_change_pct'] = None out['open'] = None out['high'] = None out['low'] = None if not target_token: out['quote_error'] = '종목 식별 실패' return out now = time.time() with _quote_cache_lock: cached = _quote_cache.get(target_token) if cached and cached[1] > now: snap = cached[0] out['price'] = snap['price'] out['day_change'] = snap['day_change'] out['day_change_pct'] = snap['day_change_pct'] out['quote_error'] = snap['quote_error'] out['open'] = snap.get('open') out['high'] = snap.get('high') out['low'] = snap.get('low') return out try: info = kc.resolve_stock_code(target_token) if not code else {'code': code} q = kc.get_stock_quote(info['code']) price = q.get('price') or 0 if not price: out['quote_error'] = '가격 0' else: out['price'] = price out['day_change'] = q.get('change') out['day_change_pct'] = q.get('change_pct') except Exception as e: out['quote_error'] = str(e)[:80] snap = { 'price': out['price'], 'day_change': out['day_change'], 'day_change_pct': out['day_change_pct'], 'quote_error': out['quote_error'], 'open': out['open'], 'high': out['high'], 'low': out['low'], } with _quote_cache_lock: _quote_cache[target_token] = (snap, time.time() + QUOTE_CACHE_TTL) return out def _holdings_map_from_positions(positions_by_acc: dict) -> dict: """positions_by_acc → 종목코드 keyed dict (워치리스트 카드 annotate용).""" agg: dict = {} for label, positions in positions_by_acc.items(): for p in positions: code = p.get('code') or '' if not code: continue cur = agg.get(code) if cur: new_qty = cur['qty'] + p['qty'] new_pur = cur['pur_amt'] + p['pur_amt'] cur['qty'] = new_qty cur['pur_amt'] = new_pur cur['avg_price'] = new_pur // new_qty if new_qty else 0 cur['accounts'].append(label) else: agg[code] = { 'qty': p['qty'], 'pur_amt': p['pur_amt'], 'avg_price': p['avg_price'], 'name': p['name'], 'accounts': [label], } return agg def _build_owner_data(positions_by_acc: dict, balances: dict, journal_by_label: dict, prev_snap_all: dict | None = None, cash_flow_by_label: dict | None = None) -> tuple[list[str], dict]: """계좌 데이터 → owner 그룹별 집계. stock_portfolio_report와 동일 로직. `당일 평가손익`은 prev_snap_all(전날 stock.briefing 스냅샷) 기반 `total_net - prev_net - net_cash_flow`. 이 차이값은 보유분의 미실현 등락 + 오늘 매매의 실현손익 + 매수/매도 현금흐름까지 자연스럽게 포함하되, 외부 입출금(cash flow)은 손익이 아니므로 net_cash_flow로 분리해 제거한다. 스냅샷이 없으면 None — KPI에 '전날 스냅샷 없음'으로 표시. 개별 카드의 day_change도 동일 스냅샷의 NXT 종가를 baseline으로 재계산해 합계와 일관성 유지. """ if cash_flow_by_label is None: cash_flow_by_label = {} from stock_portfolio_report import consolidate, aggregate_journal, group_by_owner, rebase_to_nxt_close, enrich_journal_realized_pl rows: list[dict] = [] for label, positions in positions_by_acc.items(): for p in positions: rows.append({ 'account': label, 'stock': p['name'], 'code': p['code'], 'qty': p['qty'], 'avg': p['avg_price'], 'price': p['cur_price'], 'pred_close': p.get('pred_close_pric', 0), 'day_change': p.get('day_change', 0), 'day_change_pct': p.get('day_change_pct', 0.0), 'buy_amount': p['pur_amt'], 'eval_value': p['evlt_amt'], 'profit': p['evltv_prft'], 'profit_rate': p['prft_rt'], 'tdy_buyq': p['tdy_buyq'], 'tdy_sellq': p['tdy_sellq'], }) # A-4: prev_snap 없을 때만 ka10001 보정. brifing 과 동일 정책. # 정상 운영(prev_snap 있음)에선 rebase_to_nxt_close 가 NXT-on-NXT 기준 더 정확하게 덮어쓰므로 불필요. if not prev_snap_all and rows: from stock_portfolio_report import apply_ka10001_corrections try: applied = apply_ka10001_corrections(rows) sys.stderr.write(f'[ka10001-fallback] prev_snap 없음 → {applied}개 종목 보정\n') except Exception as e: sys.stderr.write(f'ka10001 fallback failed: {e}\n') # 계좌 라벨 정렬 — set 변환의 비결정성 제거 + owner 안에서 일반→ISA 순서 고정. # suffix(언더바 뒤 또는 본인 라벨 자체)를 기준으로 일반=0, ISA=1, 그 외 사전순. def _account_label_sort_key(lb: str) -> tuple[int, str]: suffix = lb.split('_', 1)[1] if '_' in lb else lb return ({'일반': 0, 'ISA': 1}.get(suffix, 2), lb) labels = sorted({r['account'] for r in rows} | set(balances.keys()), key=_account_label_sort_key) owner_groups = group_by_owner(labels) owner_data: dict = {} for owner, lbls in owner_groups.items(): owner_rows = [r for r in rows if r['account'] in lbls] consolidated = consolidate(owner_rows) owner_journal = aggregate_journal({lb: journal_by_label.get(lb, []) for lb in lbls}) # ka10170이 어제부터 보유한 종목 매도분에 pl_amt=0 박는 케이스 보강 (스냅샷 avg_price 사용) prev_holdings_for_owner = (prev_snap_all or {}).get(owner, {}).get('holdings') or {} enrich_journal_realized_pl(owner_journal, prev_holdings_for_owner) # 당일 실현손익 합계 — 매도분만 (그로스 기준, 수수료·세금 별도) realized_pl_total = sum(j.get('pl_amt', 0) for j in owner_journal.values() if j.get('sell_qty', 0) > 0) realized_fees_total = sum(j.get('cmsn_tax', 0) for j in owner_journal.values() if j.get('sell_qty', 0) > 0) held_codes = {r['code'] for r in consolidated} for r in consolidated: j = owner_journal.get(r['code']) if j: r['journal'] = j r['tdy_buyq'] = j['buy_qty'] r['tdy_sellq'] = j['sell_qty'] r['traded_today'] = (j['buy_qty'] + j['sell_qty']) > 0 for code, j in owner_journal.items(): if code in held_codes: continue consolidated.append({ 'stock': j['name'], 'code': code, 'accounts': list(j['accounts']), 'qty': 0, 'avg': 0, 'price': 0, 'pred_close': 0, 'day_change': 0, 'day_change_pct': 0.0, 'buy_amount': 0, 'eval_value': 0, 'profit': 0, 'profit_rate': 0.0, 'tdy_buyq': j['buy_qty'], 'tdy_sellq': j['sell_qty'], 'traded_today': True, 'phantom': True, 'journal': j, }) # rebase 직전 raw KRX 종가(kt00018 pred_close_pric)를 별도 키로 보존. # 이후 ohlc-mini가 KRX/NXT 두 기준을 같이 표시할 때 사용. # NXT 미상장 코드는 별도 마커로 식별 — KRX==NXT 우연 일치 false-positive 방지. nxt_inactive_set = set((prev_snap_all or {}).get('_nxt_inactive') or []) for r in consolidated: r['pred_close_krx'] = r.get('pred_close', 0) if r.get('code') in nxt_inactive_set: r['_nxt_inactive'] = True # 개별 종목의 day_change/pred_close를 어제 NXT 종가 baseline으로 재계산. # kt00018 NXT 응답의 pred_close_pric가 KRX 종가로 채워져 raw day_change가 NXT-vs-KRX 갭으로 # 부풀려지는 문제를 해결한다. 합계 KPI는 동일 NXT 스냅샷의 prev_eval과 비교하므로 # 개별 합 ≈ 합계로 자연스럽게 일치한다. prev_holdings = (prev_snap_all or {}).get(owner, {}).get('holdings') or {} rebase_to_nxt_close(consolidated, prev_holdings) total_cost = sum(r['buy_amount'] for r in consolidated) total_value = sum(r['eval_value'] for r in consolidated) total_profit = sum(r['profit'] for r in consolidated) total_profit_rate = (total_profit / total_cost * 100) if total_cost else 0.0 # 예수금은 d2_entra(D+2 정산예수금) — 매수 즉시 차감/매도 즉시 가산되어 # 평가액 변동과 일관성 유지. entr은 D+2 결제 전까지 변하지 않아 # 신규 매수 시 손익이 매수원가만큼 부풀려지는 버그가 있다. deposit = sum(balances.get(lb, {}).get('d2_entra', 0) for lb in lbls) total_net = total_value + deposit traded_today = [r for r in consolidated if r.get('traded_today')] # 당일 평가손익 = (total_net - prev_net) - net_cash_flow (전날 stock.briefing 20:10 스냅샷 기준). prev_net = None if prev_snap_all: owner_prev = prev_snap_all.get(owner) or {} prev_eval = sum(v.get('eval_value', 0) for v in prev_holdings.values()) if prev_holdings else None prev_deposit = owner_prev.get('deposit') if prev_eval is not None and prev_deposit is not None: prev_net = prev_eval + prev_deposit # 당일 외부 입출금 — 손익이 아니므로 day_pl_total 에서 제거. owner_cash_flows: list[dict] = [] cash_in = 0 cash_out = 0 for lb in lbls: for f in cash_flow_by_label.get(lb, []) or []: owner_cash_flows.append({**f, 'label': lb}) if f['io_tp'] == 'IN': cash_in += f['amount'] elif f['io_tp'] == 'OUT': cash_out += f['amount'] net_cash_flow = cash_in - cash_out day_pl_total = (total_net - prev_net - net_cash_flow) if prev_net is not None else None # 휴장일/주말은 시장 변동이 없어야 자연. 단 NXT 가격 보정으로 어제 스냅샷과 차이가 생겨 # day_pl_total이 0이 아닌 값을 갖게 됨 → 사용자 혼란 차단 위해 None으로 강제(미표시). if _market_phase_state().get('phase') in ('holiday', 'weekend'): day_pl_total = None owner_data[owner] = { 'labels': lbls, 'consolidated': consolidated, 'rows': owner_rows, 'total_cost': total_cost, 'total_value': total_value, 'total_profit': total_profit, 'total_profit_rate': total_profit_rate, 'deposit': deposit, 'total_net': total_net, 'traded_today': traded_today, 'prev_net': prev_net, 'day_pl_total': day_pl_total, 'realized_pl_total': realized_pl_total, 'realized_fees_total': realized_fees_total, 'cash_in': cash_in, 'cash_out': cash_out, 'net_cash_flow': net_cash_flow, 'cash_flows': owner_cash_flows, } # 당일정산(phantom) 종목에 현재가 주입 — 매도 시점과 시장 종가 비교용. # ka10095 batch 1콜로 처리. 실패해도 웹뷰는 정상 응답. phantom_codes = sorted({ r['code'] for d in owner_data.values() for r in d['consolidated'] if r.get('phantom') and r.get('code') }) if phantom_codes: try: import kiwoom_client as kc live_quotes = kc.get_watchlist_quotes(phantom_codes) except Exception as e: sys.stderr.write(f'[phantom-quote] fetch failed: {e}\n') live_quotes = {} for d in owner_data.values(): for r in d['consolidated']: if r.get('phantom') and r.get('code'): q = live_quotes.get(r['code']) if q: r['live_price'] = q.get('price', 0) r['live_change_pct'] = q.get('change_pct', 0.0) ordered = sorted(owner_data.keys(), key=lambda o: (o != '본인', o)) return ordered, owner_data def _annotate_card(c: dict, holdings: dict) -> None: """카드에 보유 여부·기준가·diff·pct 부여. holdings는 코드 keyed dict. ref_price/diff는 손익 계산용: - 보유: 평단 기준 (P&L) - 감시: 매수가 기준 (entry 거리) """ code = (c.get('code') or '').strip() held = holdings.get(code) if code else None price = c.get('price') if held: c['mode'] = 'held' c['held_qty'] = held['qty'] c['held_avg_price'] = held['avg_price'] c['held_accounts'] = held['accounts'] c['ref_price'] = held['avg_price'] c['ref_label'] = '평단' else: c['mode'] = 'watching' buy = c.get('buy') if isinstance(c.get('buy'), dict) else {} c['ref_price'] = (buy or {}).get('primary') c['ref_label'] = '매수가' ref = c.get('ref_price') if price is not None and isinstance(ref, (int, float)) and ref > 0: c['diff'] = price - ref c['pct'] = (c['diff'] / ref) * 100 else: c['diff'] = None c['pct'] = None def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict: """한 페이지 로드 단위로 키움 호출을 묶는다. 병렬: - kt00018(보유종목 전체 계좌) - kt00001(예수금) × 계좌 수 - ka10170(당일매매일지 전체 계좌) - ka10095(워치리스트 종목 현재가 batch 1콜, miss 시 ka10001 단건 fallback) only_owner: '본인' / '가희' 등 owner 지정 시 해당 라벨 계좌만 호출하고 워치리스트 quotes는 skip (owner 탭 자동 갱신용 — 보유종목 가격은 kt00018 평가가에 포함, 워치리스트와 무관). 어느 분기 실패하든 다른 분기 데이터는 계속 흐른다. 워치리스트가 비어도 holdings 분기는 그대로. """ import kiwoom_client as kc from stock_portfolio_report import derive_owner try: accounts = kc.list_accounts() except Exception as e: sys.stderr.write(f'list_accounts failed: {e}\n') accounts = [] all_labels = [a['label'] for a in accounts] if only_owner: labels = [lb for lb in all_labels if derive_owner(lb) == only_owner] else: labels = all_labels quote_entries = [] if only_owner else entries with ThreadPoolExecutor(max_workers=max(len(labels), 1) + 6) as ex: # NXT 우선 가격 — 한국거래소 마감(15:30) 후에도 NXT 운영(20:00까지)으로 보유 종목 시세가 살아 움직임. # NXT 미상장 종목은 키움이 KRX 가격으로 자동 fallback. 잔고 평가(eval_value 등)도 NXT 기준 재계산. positions_f = ex.submit(kc.get_positions_all, exchange='NXT', labels=labels if only_owner else None) balance_fs = {lb: ex.submit(kc.get_balance, lb) for lb in labels} journal_f = ex.submit(kc.get_trade_journal_all, None, labels if only_owner else None) quotes_f = ex.submit(_fetch_quotes_batch, quote_entries) # 미체결 (ka10075) — 보유/감시 종목에 매수/매도대기 배지 표시용 open_orders_f = ex.submit(kc.get_open_orders_all, labels=labels) # 당일 입출금 (kt00015 tp='1') — 당일 평가손익 보정용 cash_flow_f = ex.submit(kc.get_cash_flow_all, None, labels if only_owner else None) try: positions_by_acc = positions_f.result() except Exception as e: sys.stderr.write(f'positions fetch failed: {e}\n') positions_by_acc = {} # 가격 보정 — KRX와 NXT 각각 ka10095 batch 호출. # kiwoom_client.get_watchlist_quotes 의 exchange: 'KRX'=KRX 단독 / 'NX'=NXT 단독 / 'AL'=통합. # ('NXT' 값은 함수에서 인식 안 되고 KRX 기본으로 떨어짐 — 사용 X) # phase별 우선 시장: # - regular(09:00~15:30): KRX 라이브 가격 # - nxt(08:00~09:00, 15:30~20:00): NXT 라이브 가격 # - 그 외(closed/holiday/weekend): kt00018 NXT 응답 그대로 유지 held_codes_set: set[str] = set() for _items in positions_by_acc.values(): for _p in _items: if _p.get('code'): held_codes_set.add(_p['code']) ohlc_map_krx: dict[str, dict] = {} ohlc_map_nxt: dict[str, dict] = {} if held_codes_set: try: ohlc_map_krx = kc.get_watchlist_quotes(list(held_codes_set), exchange='KRX') except Exception as e: sys.stderr.write(f'KRX positions price fetch failed: {e}\n') try: ohlc_map_nxt = kc.get_watchlist_quotes(list(held_codes_set), exchange='NX') except Exception as e: sys.stderr.write(f'NXT positions price fetch failed: {e}\n') _phase = _market_phase_state().get('phase') active_map = ohlc_map_nxt if _phase == 'nxt' else (ohlc_map_krx if _phase == 'regular' else None) if active_map: for _items in positions_by_acc.values(): for _p in _items: q = active_map.get(_p.get('code')) if not q: continue px = q.get('price', 0) if not px or px <= 0: continue pcp = _p.get('pred_close_pric', 0) or 0 # 활성 시장 응답이 거래가 없는 fallback (price == pred & change == 0)이면 덮어쓰기 X if pcp and px == pcp and (q.get('change') or 0) == 0: continue _p['cur_price'] = px qty = _p.get('qty', 0) avg = _p.get('avg_price', 0) _p['evlt_amt'] = px * qty if avg > 0: _p['evltv_prft'] = (px - avg) * qty _p['prft_rt'] = ((px - avg) / avg) * 100 ch = q.get('change') if ch is not None: _p['day_change'] = ch _p['day_change_pct'] = q.get('change_pct') or 0.0 balances: dict = {} for lb, fut in balance_fs.items(): try: balances[lb] = fut.result() except Exception as e: sys.stderr.write(f'balance fetch failed [{lb}]: {e}\n') try: journal_by_label = journal_f.result() except Exception as e: sys.stderr.write(f'journal fetch failed: {e}\n') journal_by_label = {} # 휴장일/주말엔 ka10170이 직전 영업일 데이터를 반환 → 당일매매·당일정산·라벨별 실현손익에 어제 거래가 잘못 표시. import trade_journal as tj if not tj.is_market_open(datetime.now(tj.KST)): journal_by_label = {} # 페이지 RENDER마다 ka10170 응답 그대로 trade_journal.jsonl 적재 (추가 API 호출 X). # 외부(HTS/MTS 등)에서 매매 후 자산웹 새로고침하면 즉시 거래내역 반영. 21:00 launchd가 최종 덮어씀. if journal_by_label: try: tj.record_trades(journal_by_label, quiet=True, tag='render') except Exception as e: sys.stderr.write(f'[render→journal] record_trades failed: {e}\n') cards = quotes_f.result() try: open_orders = open_orders_f.result() except Exception as e: sys.stderr.write(f'open orders fetch failed: {e}\n') open_orders = [] try: cash_flow_by_label = cash_flow_f.result() except Exception as e: sys.stderr.write(f'cash flow fetch failed: {e}\n') cash_flow_by_label = {} # 미체결 → owner별 + 전역 코드 keyed pending map # pending_by_owner[owner][code] = {'buy_qty': N, 'sell_qty': M, 'orders': [...]} # pending_global[code] = {'buy_qty': N, 'sell_qty': M, 'orders': [...]} # orders 원소: {ord_no, side, qty, price, order_type, time, account, exchange} def _empty_bucket(): return {'buy_qty': 0, 'sell_qty': 0, 'orders': []} pending_by_owner: dict[str, dict[str, dict]] = {} pending_global: dict[str, dict] = {} pending_names_by_code: dict[str, str] = {} # 미보유 종목 카드 렌더용 종목명 pending_buy_lock_by_label: dict[str, int] = {} # 라벨별 매수 미체결 묶임 금액 (지정가만) for o in open_orders: code = o.get('code') if not code: continue if o.get('name') and code not in pending_names_by_code: pending_names_by_code[code] = o['name'] owner = derive_owner(o.get('account', '')) side = o.get('side') qty = o.get('unfilled_qty', 0) if qty <= 0: continue ord_entry = { 'ord_no': o.get('ord_no', ''), 'side': side, 'qty': qty, 'price': o.get('order_price', 0), 'order_type': o.get('order_type', ''), 'time': o.get('order_time', ''), 'account': o.get('account', ''), 'exchange': o.get('exchange', ''), } bucket = pending_by_owner.setdefault(owner, {}).setdefault(code, _empty_bucket()) g = pending_global.setdefault(code, _empty_bucket()) if side == 'BUY': bucket['buy_qty'] += qty g['buy_qty'] += qty px = o.get('order_price', 0) if px > 0: pending_buy_lock_by_label[o.get('account', '')] = ( pending_buy_lock_by_label.get(o.get('account', ''), 0) + qty * px ) elif side == 'SELL': bucket['sell_qty'] += qty g['sell_qty'] += qty bucket['orders'].append(ord_entry) g['orders'].append(ord_entry) holdings_map = _holdings_map_from_positions(positions_by_acc) for c in cards: _annotate_card(c, holdings_map) # 워치리스트 카드는 owner 구분 없음 — pending_global 사용 p = pending_global.get(c.get('code') or '') if p: c['pending_buy_qty'] = p['buy_qty'] c['pending_sell_qty'] = p['sell_qty'] c['pending_orders'] = p['orders'] prev_key = None prev_snap_all: dict = {} try: from stock_portfolio_report import SNAPSHOT_FILE, _normalize_prev_snap, load_json snapshots = load_json(SNAPSHOT_FILE, {}) today_key = datetime.now(KST).strftime('%Y-%m-%d') prev_keys = sorted(k for k in snapshots if k < today_key) prev_key = prev_keys[-1] if prev_keys else None if prev_key: prev_snap_all = _normalize_prev_snap(snapshots.get(prev_key, {})) # `_watchlist_nxt` / `_nxt_inactive` 가 어제 스냅샷에 없으면 오늘 스냅샷에서 fallback — # 장중에 즉시 NXT 컬럼을 보기 위함. 오늘자는 "오늘 20:10 NXT 가격"이라 정확한 어제 마감가는 아니지만 NXT 흐름 확인엔 충분. today_raw = snapshots.get(today_key) or {} today_norm = _normalize_prev_snap(today_raw) if today_raw else {} if not (isinstance(prev_snap_all.get('_watchlist_nxt'), dict) and prev_snap_all.get('_watchlist_nxt')): if isinstance(today_norm.get('_watchlist_nxt'), dict) and today_norm['_watchlist_nxt']: prev_snap_all['_watchlist_nxt'] = today_norm['_watchlist_nxt'] if not (isinstance(prev_snap_all.get('_nxt_inactive'), list) and prev_snap_all.get('_nxt_inactive')): if isinstance(today_norm.get('_nxt_inactive'), list) and today_norm['_nxt_inactive']: prev_snap_all['_nxt_inactive'] = today_norm['_nxt_inactive'] except Exception as e: sys.stderr.write(f'snapshot load failed: {e}\n') # watchlist·interests 카드에 어제 NXT 종가 주입 — ohlc-mini의 '전일(NXT)' 컬럼 baseline. # stock.briefing 20:10 시점 NXT 가격이 _watchlist_nxt 키에 저장됨. 없는 코드는 0으로 두면 `-` placeholder. wl_nxt_map = prev_snap_all.get('_watchlist_nxt') if isinstance(prev_snap_all.get('_watchlist_nxt'), dict) else {} # NXT 미상장 코드 — KRX fallback 차단용. 보유 종목도 같이 표기되어 _pl_row가 lookup. nxt_inactive_raw = prev_snap_all.get('_nxt_inactive') if isinstance(prev_snap_all.get('_nxt_inactive'), list) else [] nxt_inactive_set = set(nxt_inactive_raw) if wl_nxt_map or nxt_inactive_set: for c in cards: code = c.get('code') if not code: continue if code in nxt_inactive_set: c['_nxt_inactive'] = True elif code in wl_nxt_map: c['_pred_close_nxt'] = wl_nxt_map[code] # 매수 미체결 묶임 금액을 balances에 라벨별로 주입 (KPI·계좌별 예수금 부연 표시용) for lb in balances: balances[lb]['pending_buy_locked'] = pending_buy_lock_by_label.get(lb, 0) try: ordered_owners, owner_data = _build_owner_data(positions_by_acc, balances, journal_by_label, prev_snap_all, cash_flow_by_label) except Exception as e: sys.stderr.write(f'owner aggregate failed: {e}\n') traceback.print_exc() ordered_owners, owner_data = [], {} # owner별 매수 묶임 합산 for owner_name, od in owner_data.items(): od['pending_buy_locked'] = sum( balances.get(lb, {}).get('pending_buy_locked', 0) for lb in od.get('labels', []) ) # consolidated/phantom 행에 미체결 수량 주입 (owner 단위) for owner_name, od in owner_data.items(): owner_pending = pending_by_owner.get(owner_name, {}) for r in od.get('consolidated', []): p = owner_pending.get(r.get('code') or '') if p: r['pending_buy_qty'] = p['buy_qty'] r['pending_sell_qty'] = p['sell_qty'] r['pending_orders'] = p['orders'] for r in od.get('phantom', []) if isinstance(od.get('phantom'), list) else []: p = owner_pending.get(r.get('code') or '') if p: r['pending_buy_qty'] = p['buy_qty'] r['pending_sell_qty'] = p['sell_qty'] r['pending_orders'] = p['orders'] # 미체결 매수 주문 중 owner 미보유 종목 → owner 패널 별도 섹션 노출. # consolidated에 들어가지 않은 코드만 추림 (보유 + 당일정산 모두 제외). unheld_codes_all: set[str] = set() for owner_name, od in owner_data.items(): present_codes = {r.get('code') for r in od.get('consolidated', []) if r.get('code')} owner_pending = pending_by_owner.get(owner_name, {}) unheld_rows: list[dict] = [] for code, p in owner_pending.items(): if code in present_codes or p['buy_qty'] <= 0: continue unheld_rows.append({ 'code': code, 'stock': pending_names_by_code.get(code, ''), 'pending_buy_qty': p['buy_qty'], 'pending_sell_qty': p['sell_qty'], 'pending_orders': list(p['orders']), }) unheld_codes_all.add(code) unheld_rows.sort(key=lambda r: -r.get('pending_buy_qty', 0)) od['pending_unheld'] = unheld_rows # 미보유 미체결 종목 현재가 batch (ka10095). 캐시 hit이면 즉시 반환. if unheld_codes_all: try: unheld_quotes = kc.get_watchlist_quotes(sorted(unheld_codes_all)) except Exception as e: sys.stderr.write(f'[pending-unheld] quote batch failed: {e}\n') unheld_quotes = {} for od in owner_data.values(): for r in od.get('pending_unheld', []): q = unheld_quotes.get(r['code']) if q: r['price'] = q.get('price') or 0 r['day_change'] = q.get('change') r['day_change_pct'] = q.get('change_pct') r['open'] = q.get('open') # 미리보기 등락은 시가 대비 → open 필요 # 보유 종목 OHLC batch — 카드 펼치면 보이는 오늘 단일 캔들 + 자세히 보기 mini-table용. # ohlc_map_krx (exchange='KRX')와 ohlc_map_nxt (exchange='NX'=NXT 단독)는 positions 보정에서 이미 호출 — 재사용. # 각 컬럼은 그 시장 데이터로 정확히 채움 (서로 섞이지 않도록). primary 단일 캔들은 phase 우선. if ohlc_map_krx or ohlc_map_nxt: _phase = _market_phase_state().get('phase') for od in owner_data.values(): for r in od.get('consolidated', []): code = r.get('code') qk = ohlc_map_krx.get(code) if code else None qn = ohlc_map_nxt.get(code) if code else None # price 만 있어도 valid — 거래 없는 시간대(시가/고가/저가=0)도 종가만 표시되도록. qk_valid = bool(qk and qk.get('price')) qn_valid = bool(qn and qn.get('price')) if qk_valid: r['ohlc_krx'] = { 'open': qk['open'], 'high': qk['high'], 'low': qk['low'], 'price': qk.get('price', 0), 'change_pct': qk.get('change_pct', 0.0), 'change': qk.get('change', 0), } if qn_valid: r['ohlc_nxt'] = { 'open': qn['open'], 'high': qn['high'], 'low': qn['low'], 'price': qn.get('price', 0), 'change_pct': qn.get('change_pct', 0.0), 'change': qn.get('change', 0), } # 단일 캔들 primary — 4값(open/high/low/price) 모두 있는 응답만 후보. 거래 없는 시간대 0값 응답 배제. qk_full = qk_valid and qk.get('open') and qk.get('high') and qk.get('low') qn_full = qn_valid and qn.get('open') and qn.get('high') and qn.get('low') if _phase == 'regular': primary = qk if qk_full else (qn if qn_full else None) else: primary = qn if qn_full else (qk if qk_full else None) if primary: r['ohlc'] = {'o': primary['open'], 'h': primary['high'], 'l': primary['low'], 'c': primary.get('price', 0) or r.get('price', 0)} return { 'cards': cards, 'balances': balances, 'ordered_owners': ordered_owners, 'owner_data': owner_data, 'journal_by_label': journal_by_label, 'only_owner': only_owner, } def _apply_watchlist_action(action: str, stock: str) -> None: """delete = pending_delete로 표시. restore = active로 복귀. purge = 완전 삭제. behive_youtube_digest.watchlist_lock과 동일한 lockfile을 사용해 cron save와의 race를 차단한다. """ import behive_youtube_digest as byd with byd.watchlist_lock(): if not WATCHLIST.exists(): raise KeyError(stock) data = json.loads(WATCHLIST.read_text()) if stock not in data: raise KeyError(stock) if action == 'delete': data[stock]['status'] = 'pending_delete' data[stock]['pending_delete_at'] = datetime.now(KST).isoformat() elif action == 'restore': data[stock].pop('status', None) data[stock].pop('pending_delete_at', None) elif action == 'purge': data.pop(stock) else: raise ValueError(action) tmp = WATCHLIST.with_suffix('.json.tmp') tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2)) tmp.replace(WATCHLIST) def _load_alerts_map() -> dict[str, list[str]]: """state/watchlist_alerts.json → {stock: [conds]}. 신규/레거시 포맷 모두 호환. 렌더링 전용 read-only — 락 안 잡음(다음 monitor가 신규 포맷으로 일관 정리).""" if not ALERTS_STATE.exists(): return {} try: raw = json.loads(ALERTS_STATE.read_text()) except Exception: return {} if not isinstance(raw, dict): return {} if isinstance(raw.get('alerts'), dict): return {k: list(v) for k, v in raw['alerts'].items() if isinstance(v, list)} # 레거시 date-keyed: 모든 날짜 머지 merged: dict[str, list[str]] = {} for v in raw.values(): if not isinstance(v, dict): continue for s, conds in v.items(): if not isinstance(conds, list): continue bucket = merged.setdefault(s, []) for c in conds: if isinstance(c, str) and c not in bucket: bucket.append(c) return merged def _apply_alerts_reset(stock: str) -> None: """해당 종목의 알림 dedup 기록 전부 삭제 → 다음 monitor 사이클에서 재알림 가능. watchlist_monitor.alerts_lock과 동일 lockfile 사용. 종목이 state 에 없으면 no-op (404 아님 — 한 번도 알림 안 떴거나 이미 비어 있는 정상 상태).""" import watchlist_monitor as wm with wm.alerts_lock(): state = wm.load_alerts_state() alerts = state.setdefault('alerts', {}) alerts.pop(stock, None) tmp = ALERTS_STATE.with_suffix('.json.tmp') ALERTS_STATE.parent.mkdir(parents=True, exist_ok=True) tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2)) tmp.replace(ALERTS_STATE) class MultipleMatchError(Exception): """다중 매칭. do_POST에서 후보 JSON 응답으로 분기.""" def __init__(self, query: str, candidates: list[dict]): self.query = query self.candidates = candidates super().__init__(query) def _resolve_stock_fuzzy(query: str) -> dict: """정확 매칭(case-sensitive) → substring(case-insensitive) 단일 → 다중이면 후보 응답. 'lg' 입력 시 'LG' 단독 자동 통과하지 않고 'LG전자' 등도 함께 후보로 노출되도록 case-insensitive exact 단계는 제외. 후보 정렬은 ci-exact > prefix > 나머지.""" import kiwoom_client as kc q = (query or '').strip() if not q: raise ValueError('빈 입력') cache = kc._load_code_cache() if q in cache: return cache[q] if q.isdigit() and len(q) == 6: try: return kc.resolve_stock_code(q) except KeyError: raise ValueError(f'코드 "{q}" 조회 실패') ql = q.lower() matched = [info for name, info in cache.items() if ql in name.lower()] if len(matched) == 1: return matched[0] if len(matched) > 1: def sort_key(info: dict) -> tuple: n = info['name'].lower() if n == ql: return (0, n) if n.startswith(ql): return (1, n) return (2, n) matched.sort(key=sort_key) raise MultipleMatchError(q, matched[:30]) try: return kc.resolve_stock_code(q) except KeyError: raise ValueError(f'"{q}"와 일치하는 종목을 찾을 수 없습니다. 종목명 또는 6자리 종목코드를 입력해주세요.') _INTEREST_PRICE_LABEL = {'buy_price': '매수가', 'target_price': '목표가', 'stop_price': '손절가'} def _interests_lock(): """프로세스간 INTERESTS read-modify-write 직렬화. byd.watchlist_lock 패턴 동일.""" import fcntl as _fcntl from contextlib import contextmanager as _cm @_cm def _ctx(): lock_path = INTERESTS.with_suffix(INTERESTS.suffix + '.lock') lock_path.parent.mkdir(parents=True, exist_ok=True) f = open(lock_path, 'a') try: _fcntl.flock(f.fileno(), _fcntl.LOCK_EX) yield finally: try: _fcntl.flock(f.fileno(), _fcntl.LOCK_UN) finally: f.close() return _ctx() def _stock_tags_lock(): """STOCK_TAGS 파일 직렬화 — interests_lock 와 동일 패턴, 별도 lock 파일.""" import fcntl as _fcntl from contextlib import contextmanager as _cm @_cm def _ctx(): lock_path = STOCK_TAGS.with_suffix(STOCK_TAGS.suffix + '.lock') lock_path.parent.mkdir(parents=True, exist_ok=True) f = open(lock_path, 'a') try: _fcntl.flock(f.fileno(), _fcntl.LOCK_EX) yield finally: try: _fcntl.flock(f.fileno(), _fcntl.LOCK_UN) finally: f.close() return _ctx() def _load_stock_tags() -> dict: """{'by_code': {code: tag}, 'by_name': {name: tag}}. 종목 공용 태그 저장소.""" if not STOCK_TAGS.exists(): return {'by_code': {}, 'by_name': {}} try: d = json.loads(STOCK_TAGS.read_text()) if 'by_code' not in d: d['by_code'] = {} if 'by_name' not in d: d['by_name'] = {} return d except Exception: return {'by_code': {}, 'by_name': {}} def _get_stock_tag(code: str | None, name: str | None) -> dict: """{'text': str, 'leader': bool} 반환. 항목 없으면 {}. code 우선 → name fallback. 구버전(문자열) 호환: 'X' → {'text': 'X', 'leader': X=='대장'} 변환.""" if not code and not name: return {} tags = _load_stock_tags() for src_key, key in (('by_code', code), ('by_name', name)): if not key: continue v = tags.get(src_key, {}).get(key) if not v: continue if isinstance(v, str): txt = v.strip() return {'text': txt, 'leader': txt == '대장'} if isinstance(v, dict): txt = (v.get('text') or '').strip() ldr = bool(v.get('leader')) if txt or ldr: return {'text': txt, 'leader': ldr} return {} def _tag_chip_html(code: str, name: str, interactive: bool = False) -> str: """종목명 옆 태그 칩 — 표시 전용 (클릭 비활성). 수정은 detail 안 '+ 태그' 버튼에서만. - text 있을 때만 렌더 (leader 단독은 칩 미노출, 토글 상태만 보관) - leader=True → 빨강(tag-leader), 그 외 → 노랑(tag-chip) - interactive 인자는 후방 호환 위해 받지만 더 이상 사용 안 함 (항상 span).""" code_s = (code or '').strip() name_s = (name or '').strip() if not code_s and not name_s: return '' t = _get_stock_tag(code_s, name_s) text = t.get('text', '') leader = bool(t.get('leader')) if not text: return '' cls = 'tag-chip tag-leader' if leader else 'tag-chip' chip_text = html.escape('#' + text) return f'{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) def _apply_interests_action(action: str, stock: str, payload: dict | None = None) -> None: """관심종목 add/delete. 자동 적재 없는 수동 리스트라 pending_delete trash 단계 없이 즉시 삭제.""" INTERESTS.parent.mkdir(parents=True, exist_ok=True) with _interests_lock(): _apply_interests_action_locked(action, stock, payload) def _apply_interests_action_locked(action: str, stock: str, payload: dict | None = None) -> None: data: dict = {} if INTERESTS.exists(): try: data = json.loads(INTERESTS.read_text()) except Exception as e: # 손상된 JSON은 timestamp 백업 후 빈 dict로 시작 — 다음 write로 silent 덮어쓰기 방지 backup = INTERESTS.with_name(INTERESTS.name + f'.corrupt.{int(time.time())}') try: INTERESTS.rename(backup) sys.stderr.write(f'INTERESTS json 손상 — {backup}로 백업: {e}\n') except Exception: pass data = {} if action == 'add': payload = payload or {} code = (payload.get('code') or '').strip() if not stock and not code: raise ValueError('종목명 또는 종목코드 중 하나는 입력해주세요') if code: # 코드 우선 lookup → 정식 종목명으로 통일. stock+code 동시 입력 시 사용자 stock 오타도 교정. import kiwoom_client as kc try: info = kc.resolve_stock_code(code) stock = info.get('name') or stock or code code = info.get('code') or code except Exception: if not stock: stock = code else: # 종목명만 입력 — fuzzy 매칭으로 코드 lookup info = _resolve_stock_fuzzy(stock) stock = info.get('name') or stock code = info.get('code', '') if stock in data: raise ValueError(f'이미 등록된 종목입니다: {stock} — 수정으로 변경하세요') entry: dict = {'stock': stock, 'saved_at': datetime.now(KST).isoformat()} if code: entry['code'] = code for src_key, dst_key, sub in (('buy_price', 'buy', 'primary'), ('target_price', 'target', 'low'), ('stop_price', 'stop', 'value')): raw = (payload.get(src_key) or '').strip() if not raw: continue try: num = float(raw.replace(',', '')) except ValueError: raise ValueError(f'{_INTEREST_PRICE_LABEL[src_key]} 형식 오류: "{raw}" — 숫자만 입력해주세요') entry[dst_key] = {sub: num, 'raw': f'{int(num):,}원' if num == int(num) else f'{num:,}원'} memo = (payload.get('memo') or '').strip() if memo: entry['memo'] = memo entry['notes'] = [memo] data[stock] = entry elif action == 'update': if stock not in data: raise KeyError(stock) payload = payload or {} entry = data[stock] for src_key, dst_key, sub in (('buy_price', 'buy', 'primary'), ('target_price', 'target', 'low'), ('stop_price', 'stop', 'value')): if src_key not in payload: continue raw = (payload.get(src_key) or '').strip() if not raw: entry.pop(dst_key, None) continue try: num = float(raw.replace(',', '')) except ValueError: raise ValueError(f'{_INTEREST_PRICE_LABEL[src_key]} 형식 오류: "{raw}" — 숫자만 입력해주세요') entry[dst_key] = {sub: num, 'raw': f'{int(num):,}원' if num == int(num) else f'{num:,}원'} if 'memo' in payload: memo = (payload.get('memo') or '').strip() if memo: entry['memo'] = memo entry['notes'] = [memo] else: entry.pop('memo', None) entry.pop('notes', None) data[stock] = entry elif action == 'delete': if stock not in data: raise KeyError(stock) data.pop(stock) else: raise ValueError(f'unknown action: {action}') tmp = INTERESTS.with_suffix('.json.tmp') tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2)) tmp.replace(INTERESTS) def _fmt_price_field(field) -> str: if not isinstance(field, dict): return '언급 없음' raw = (field.get('raw') or '').strip() return raw or '언급 없음' def _near_buy_tier(c: dict) -> int: """감시 모드 매수 가능권 단계. 0 = 해당없음 (매수가 위 5% 초과) 1 = 매수가 위 3~5% (노랑, 멀리서 접근) 2 = 매수가 위 0~3% (주황, 임박) 3 = 매수가 도달/하회 (빨강, 매수권 진입) """ if c.get('mode') != 'watching': return 0 pct = c.get('pct') if not isinstance(pct, (int, float)): return 0 if pct > 5: return 0 if pct > 3: return 1 if pct > 0: return 2 return 3 def _color_class(mode: str, price, ref_price, stop_value) -> str: """mode 별 색상. held(보유): 초록=수익, 빨강=손실, 회색=동가 watching(감시): 초록=매수가 도달, 빨강=손절가 하회, 회색=아직 매수가 위 대기 """ if price is None or not isinstance(ref_price, (int, float)) or ref_price <= 0: return 'neutral' if mode == 'held': if price > ref_price: return 'up' if price < ref_price: return 'down' return 'neutral' if isinstance(stop_value, (int, float)) and stop_value > 0 and price <= stop_value: return 'down' if price <= ref_price: return 'up' return 'neutral' def _buy_dd_html(buy_field, alerted: set[str]) -> str: """매수가 dd 콘텐츠 — raw 라벨 + 레벨별 알림 마커. levels 가 없거나 raw 가 단일 가격이면 단일 줄. 다중 레벨이면 각 레벨에 마커.""" if not isinstance(buy_field, dict): return '언급 없음' raw = (buy_field.get('raw') or '').strip() levels = [int(lv) for lv in (buy_field.get('levels') or []) if isinstance(lv, (int, float)) and lv > 0] if not levels: return html.escape(raw) if raw else '언급 없음' norm_raw = raw.replace(',', '').replace('원', '').replace(' ', '') single_simple = len(levels) == 1 and norm_raw.isdigit() and int(norm_raw) == levels[0] parts: list[str] = [] if raw and not single_simple: parts.append(f'
{html.escape(raw)}
') for lv in levels: mark = ' 알림됨' if f'buy@{lv}' in alerted else '' parts.append(f'
{lv:,}원{mark}
') return ''.join(parts) def _render_row(c: dict, source: str = 'watchlist') -> str: stock = html.escape(c.get('stock', '')) code = html.escape(c.get('code', '') or '') price = c.get('price') err = c.get('quote_error') mode = c.get('mode', 'watching') ref_price = c.get('ref_price') ref_label = c.get('ref_label', '매수가') stop_dict = c.get('stop') if isinstance(c.get('stop'), dict) else {} cls = _color_class(mode, price, ref_price, (stop_dict or {}).get('value')) tier = _near_buy_tier(c) dot_html = f'' if tier else '' if price is None: price_cell = '-' diff_cell = f'{html.escape(err) if err else "조회 불가"}' else: # 가격 출처 마크 — _krx_price/_nxt_price 비교. NXT 가격이 살아있고 KRX와 다르면 NXT. krx_p = c.get('_krx_price', 0) or 0 nxt_p = c.get('_nxt_price', 0) or 0 if nxt_p > 0 and (krx_p == 0 or krx_p != nxt_p): mark = 'NXT' else: mark = 'KRX' mark_html = f'{mark}' # 미리보기 등락은 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 = '' if code: chart_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, 그 외는 매수 default. 모달에서 토글 가능. order_side = 'SELL' if c.get('mode') == 'held' else 'BUY' order_btn = ( f'' ) if raw_code else '' if source == 'interests': def _num_attr(field, sub): if not isinstance(field, dict): return '' v = (field or {}).get(sub) if isinstance(v, (int, float)) and v > 0: return str(int(v)) if v == int(v) else str(v) return '' buy_attr_v = _num_attr(c.get('buy'), 'primary') target_attr_v = _num_attr(c.get('target'), 'low') stop_attr_v = _num_attr(c.get('stop'), 'value') memo_val = c.get('memo') or '' if not memo_val: notes = c.get('notes') or [] if notes and isinstance(notes[0], str): memo_val = notes[0] edit_attrs = ( f' data-modal-open="interest-edit-modal"' f' data-edit-stock="{stock_attr}"' f' data-edit-code="{html.escape(c.get("code") or "", quote=True)}"' f' data-edit-buy="{html.escape(buy_attr_v, quote=True)}"' f' data-edit-target="{html.escape(target_attr_v, quote=True)}"' f' data-edit-stop="{html.escape(stop_attr_v, quote=True)}"' f' data-edit-memo="{html.escape(memo_val, quote=True)}"' ) tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '') info_btn = _info_button_html(c.get('code') or '', c.get('stock') or '') actions_html = ( '
    ' f'{tag_add_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_tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '') wl_info_btn = _info_button_html(c.get('code') or '', c.get('stock') or '') actions_html = ( '
    ' f'{wl_tag_add_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}
    {price_cell}
    {f'
    {diff_cell}
    ' if diff_cell else ''}
    {'
    최초 알림 완료 — 리셋 전까지 매수 알림 없음
    ' 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} {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' def _render_owner_kpi(owner: str, d: dict, owner_label_text: str, compact: bool = False) -> str: """owner 블록 상단 KPI 카드 (총 평가/손익/예수금/순자산 + 계좌별 예수금). compact=True: 자산정보 탭용 — 당일 매매·당일 실현손익 행 생략. """ profit = d['total_profit'] profit_rate = d['total_profit_rate'] pcls = _profit_class(profit) held_count = len([r for r in d['consolidated'] if not r.get('phantom')]) sign = '+' if profit >= 0 else '' labels_disp = ' + '.join(d['labels']) day_pl_total = d.get('day_pl_total') if day_pl_total is None: # 휴장일/주말은 시장 변동이 없는 정상 상태 — "전날 스냅샷 없음"과 구분된 문구. if _market_phase_state().get('phase') in ('holiday', 'weekend'): day_pl_html = '휴장 — 변동 없음' else: day_pl_html = '전날 스냅샷 없음' else: dcls = _profit_class(day_pl_total) dsign = '+' if day_pl_total >= 0 else '' prev_net = d.get('prev_net') or 0 pct_html = '' if prev_net: pct = (day_pl_total / prev_net) * 100 pct_html = f' ({dsign}{pct:.2f}%)' day_pl_html = f'{dsign}{day_pl_total:,}원{pct_html}' deposit_pct_html = '' if d['total_value']: dep_pct = (d['deposit'] / d['total_value']) * 100 deposit_pct_html = f'({dep_pct:.1f}%) ' locked = d.get('pending_buy_locked', 0) or 0 deposit_locked_html = ( f' · 매수 묶임 {locked:,}원' if locked > 0 else '' ) kpis = [ ('총 평가금액', f'{d["total_value"]:,}원'), ('총 매입금액', f'{d["total_cost"]:,}원'), ('총 평가손익', f'{sign}{profit:,}원 ({sign}{profit_rate:.2f}%)'), ('당일 평가손익', day_pl_html), ('예수금', f'{deposit_pct_html}{d["deposit"]:,}원{deposit_locked_html}'), ('순자산', f'{d["total_net"]:,}원'), ] cash_in = d.get('cash_in', 0) cash_out = d.get('cash_out', 0) if cash_in or cash_out: net = cash_in - cash_out ncls = _profit_class(net) nsign = '+' if net >= 0 else '' cf_detail = [] if cash_in: cf_detail.append(f'입금 {cash_in:,}원') if cash_out: cf_detail.append(f'출금 {cash_out:,}원') cf_detail_html = f' · {" · ".join(cf_detail)}' kpis.append(('당일 입출금', f'{nsign}{net:,}원{cf_detail_html}')) if compact: kpis.append(('보유 종목', f'{held_count}개')) else: traded_count = len(d["traded_today"]) kpis.append(('보유종목/당일매매', f'{held_count}개 / {traded_count}개')) realized_pl = d.get('realized_pl_total', 0) realized_fees = d.get('realized_fees_total', 0) if d.get('traded_today') and (realized_pl or realized_fees): rcls = _profit_class(realized_pl) rsign = '+' if realized_pl >= 0 else '' fee_html = f' · 수수료·세금 {realized_fees:,}원' if realized_fees else '' realized_html = f'{rsign}{realized_pl:,}원{fee_html}' kpis.append(('당일 실현손익', realized_html)) rows = ''.join( f'{html.escape(k)}{v}' for k, v in kpis ) trade_btn_html = '' if not compact: owner_attr = html.escape(owner, quote=True) label_attr = html.escape(owner_label_text, quote=True) trade_btn_html = ( f'' ) return f'''
    {html.escape(owner_label_text)}
    {trade_btn_html}
    {html.escape(labels_disp)}
    {rows}
    ''' def _render_account_kpi(owner: str, label: str, owner_d: dict, balances: dict, journal_by_label: dict, owner_label_text: str, has_prev_snap: bool, label_disp: str | None = None) -> str: """단일 계좌 단위 compact KPI 카드. owner_d['rows'] 를 account==label 로 필터해 합산. 합산 뷰의 `_render_owner_kpi` 와 동일한 라벨·순서지만, 데이터 출처가 owner 합 대신 단일 라벨이라 당일 평가손익은 owner-level 전날 스냅샷(holdings는 owner 합)이라 정확한 분해가 불가 → `(day_change × qty)` 합으로 근사하고 `근사` 표기. 합계 뷰의 prev_net 기반 값은 owner 카드에서 확인. """ rows = [r for r in owner_d.get('rows', []) if r.get('account') == label] total_value = sum(r.get('eval_value', 0) for r in rows) total_cost = sum(r.get('buy_amount', 0) for r in rows) total_profit = sum(r.get('profit', 0) for r in rows) total_profit_rate = (total_profit / total_cost * 100) if total_cost else 0.0 # 종목 단위 unique count (한 라벨 내에선 중복 없지만 안전) held_count = len({r.get('code') for r in rows if r.get('qty', 0) > 0 and r.get('code')}) bal = balances.get(label) or {} deposit = bal.get('d2_entra', 0) or 0 total_net = total_value + deposit locked = bal.get('pending_buy_locked', 0) or 0 # 당일 평가손익 — 라벨별 prev_snap이 없어 근사 (day_change × qty). 실현손익·cash_flow는 포함 안 됨. if has_prev_snap and rows: approx_day_pl = sum(int(r.get('day_change', 0) or 0) * int(r.get('qty', 0) or 0) for r in rows) else: approx_day_pl = None # 라벨별 실현손익·당일매매 — journal_by_label[label] 에서 직접 추출 label_journal = journal_by_label.get(label, []) or [] sell_entries = [j for j in label_journal if (j.get('sell_qty') or 0) > 0] realized_pl = sum(int(j.get('pl_amt', 0) or 0) for j in sell_entries) realized_fees = sum(int(j.get('cmsn_tax', 0) or 0) for j in sell_entries) traded_today_codes = {j.get('code') for j in label_journal if ((j.get('buy_qty') or 0) + (j.get('sell_qty') or 0)) > 0 and j.get('code')} sign = '+' if total_profit >= 0 else '' pcls = _profit_class(total_profit) if approx_day_pl is None: day_pl_html = '전날 스냅샷 없음' else: dcls = _profit_class(approx_day_pl) dsign = '+' if approx_day_pl >= 0 else '' day_pl_html = f'{dsign}{approx_day_pl:,}원 · 근사' deposit_pct_html = '' if total_value: dep_pct = (deposit / total_value) * 100 deposit_pct_html = f'({dep_pct:.1f}%) ' deposit_locked_html = ( f' · 매수 묶임 {locked:,}원' if locked > 0 else '' ) kpis = [ ('총 평가금액', f'{total_value:,}원'), ('총 매입금액', f'{total_cost:,}원'), ('총 평가손익', f'{sign}{total_profit:,}원 ({sign}{total_profit_rate:.2f}%)'), ('당일 평가손익', day_pl_html), ('예수금', f'{deposit_pct_html}{deposit:,}원{deposit_locked_html}'), ('순자산', f'{total_net:,}원'), ('보유 종목', f'{held_count}개'), ] if traded_today_codes and (realized_pl or realized_fees): rcls = _profit_class(realized_pl) rsign = '+' if realized_pl >= 0 else '' fee_html = f' · 수수료·세금 {realized_fees:,}원' if realized_fees else '' kpis.append(('당일 실현손익', f'{rsign}{realized_pl:,}원{fee_html}')) rows_html = ''.join( f'{html.escape(k)}{v}' for k, v in kpis ) sub_text = label_disp if label_disp is not None else label return f'''
    {html.escape(owner_label_text)}
    {html.escape(sub_text)}
    {rows_html}
    ''' def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: str = 'none') -> str: """보유종목 자세히 보기용 OHLC mini-table — KRX/NXT 두 거래소 가격·등락률 분리 표시. 각 dict: {open, high, low, price, change_pct} (price=종가/현재가, change_pct=직전 영업일 종가 대비). 한쪽이 None이면 해당 컬럼 전체 '-' placeholder. 4행(현재가/시가/고가/저가) × 2컬럼(KRX/NXT). active_market: 'krx' / 'nxt' / 'none'. 활성 시장 컬럼은 기본 색, 비활성은 회색 처리. """ # price 만 있어도 컬럼 표시 — 거래 없는 시간대도 종가 노출. open/high/low 0 셀은 _cell 안에서 '-' 처리. has_krx = bool(krx and krx.get('price')) has_nxt = bool(nxt and nxt.get('price')) if not (has_krx or has_nxt): return '' # NXT 시가 baseline 은 호출처에서 어제 NXT 스냅샷 종가(r['pred_close'])로 미리 박아 전달. # 자동 baseline 계산(price - change)은 호출처가 못 박은 경우만 fallback. if has_nxt and nxt and not nxt.get('open'): nxt_baseline = (nxt.get('price', 0) or 0) - (nxt.get('change', 0) or 0) if nxt_baseline > 0: nxt = dict(nxt) nxt['open'] = nxt_baseline def _cell(d: dict | None, key: str, dim: bool) -> str: dim_cls = ' ohlc-dim' if dim else '' if not d: return f'-' if key == 'price': price = d.get('price', 0) pct = d.get('change_pct', 0.0) or 0.0 elif key == 'prev_close': # 전 거래일 종가 — baseline 자체라 등락률 표시 없음. price = d.get('prev_close', 0) if not price: return f'-' return f'{price:,}원' else: price = d.get(key, 0) base = d.get('price', 0) - d.get('change', 0) pct = ((price - base) / base * 100) if base else 0.0 if not price: return f'-' cls = 'up' if pct > 0 else ('down' if pct < 0 else 'neutral') sgn = '+' if pct >= 0 else '' return ( f'{price:,}원' f'{sgn}{pct:.2f}%' ) krx_dim = active_market not in ('krx', 'none-active-krx') # 'krx' 활성 또는 양쪽 비활성-krx강조 시만 밝게 nxt_dim = active_market != 'nxt' # 'none' (양쪽 비활성, 휴장/심야) — 둘 다 dim if active_market == 'none': krx_dim = True nxt_dim = True elif active_market == 'krx': krx_dim = False nxt_dim = True elif active_market == 'nxt': krx_dim = True nxt_dim = False rows = [ ('전일종가', 'prev_close'), ('현재가', 'price'), ('시가', 'open'), ('고가', 'high'), ('저가', 'low'), ] krx_th_dim = ' ohlc-dim' if krx_dim else '' nxt_th_dim = ' ohlc-dim' if nxt_dim else '' parts = [ '
    ', '', f'KRX', f'NXT', ] for lbl, key in rows: parts.append(f'{lbl}') parts.append(_cell(krx if has_krx else None, key, krx_dim)) parts.append(_cell(nxt if has_nxt else None, key, nxt_dim)) parts.append('
    ') return ''.join(parts) def _render_ohlc_mini(o: int, h: int, l: int, current: int, pred_krx: int, pred_nxt: int) -> str: """OHLC 4행 mini-table — 라벨/가격/전일(KRX)/전일(NXT) 4-col grid. 헤더 row 한 줄에 '전일(KRX)' '전일(NXT)' 컬럼명, 데이터 row는 4행(현재가/시가/고가/저가). pred_krx/pred_nxt 중 하나가 0이면 해당 컬럼은 모두 `-` placeholder — 컬럼 레이아웃 유지. KRX/NXT 기준이 같은 종목(NXT 미운영)에선 두 컬럼 값이 동일. """ if not (o > 0 and h > 0 and l > 0 and current > 0): return '' def _pct_cell(p: int, base: int) -> str: if not base or not p: return '-' d = p - base pct = d / base * 100 cls = 'up' if d > 0 else ('down' if d < 0 else 'neutral') sgn = '+' if d >= 0 else '' return f'{sgn}{pct:.2f}%' rows = [ ('현재가', current), ('시가', o), ('고가', h), ('저가', l), ] parts = [ '
    ', '', '', 'KRX', 'NXT', ] for lbl, p in rows: parts.append(f'{lbl}') parts.append(f'{p:,}원') parts.append(_pct_cell(p, pred_krx)) parts.append(_pct_cell(p, pred_nxt)) parts.append('
    ') return ''.join(parts) def _render_single_candle(o: int, h: int, l: int, c: int, width: int = 64, height: int = 110) -> str: """오늘 하루 단일 캔들 SVG. 시/고/저/현 4값으로 박스+wick. 빨강=상승, 파랑=하락 (한국 관례).""" if not (o > 0 and h > 0 and l > 0 and c > 0): return '' if h == l: return '' pad_top, pad_bot = 6, 6 plot_h = height - pad_top - pad_bot def y(price: int) -> float: return pad_top + (h - price) / (h - l) * plot_h cx = width / 2 body_top = y(max(o, c)) body_bot = y(min(o, c)) body_h = max(body_bot - body_top, 1.0) wick_top = y(h) wick_bot = y(l) color = '#ff4d5e' if c >= o else '#4d8cff' body_w = max(width * 0.55, 14) body_x = cx - body_w / 2 return ( f'' ) _EMPTY_KV_PAIR = '
    ' def _interleave_kv_pairs(left: list[str], right: list[str]) -> str: """좌·우 dt-dd 쌍 리스트를 4컬럼 row-major grid 순서로 인터리브.""" n = max(len(left), len(right)) out: list[str] = [] for i in range(n): out.append(left[i] if i < len(left) else _EMPTY_KV_PAIR) out.append(right[i] if i < len(right) else _EMPTY_KV_PAIR) return ''.join(out) def _pending_badges_html(r: dict) -> str: """미리보기 💰 배지 — 색만으로 매수(빨강)/매도(파랑) 구분. 수량·상세 정보는 자세히보기(`_pending_detail_html`)에서. """ buy = r.get('pending_buy_qty', 0) sell = r.get('pending_sell_qty', 0) parts: list[str] = [] if buy: parts.append( f'💰' ) if sell: parts.append( f'💰' ) return ''.join(parts) def _fmt_hhmmss(raw: str) -> str: """ka10075 'tm' 필드 'HHMMSS' → 'HH:MM:SS'. 이미 콜론이거나 비표준이면 그대로.""" s = (raw or '').strip() if not s or ':' in s: return s if len(s) == 6 and s.isdigit(): return f'{s[0:2]}:{s[2:4]}:{s[4:6]}' return s def _pending_detail_html(r: dict) -> str: """자세히보기용 미체결 ord 단위 상세. 매수/매도 분리 표시. `r['pending_orders']` 가 ord별 dict 리스트. 레이아웃: dt(라벨, 컬러) / dd 2줄(상단=수량@가격, 하단=계좌·시각·주문번호 muted). """ orders = r.get('pending_orders') or [] if not orders: return '' buys = [o for o in orders if o.get('side') == 'BUY'] sells = [o for o in orders if o.get('side') == 'SELL'] def _row(o: dict, kind: str) -> str: qty = o.get('qty', 0) price = o.get('price', 0) otype = o.get('order_type') or '' price_str = f'{price:,}원 지정가' if price else (otype or '시장가') acc = html.escape(o.get('account', '')) ord_no = html.escape(o.get('ord_no', '')) tm = _fmt_hhmmss(html.escape(o.get('time', ''))) meta_parts = [acc] if tm: meta_parts.append(tm) if ord_no: meta_parts.append(f'주문번호 {ord_no}') label = '미체결 매수' if kind == 'buy' else '미체결 매도' return ( f'
    {label}
    ' f'
    ' f'
    {qty:,}주 @ {price_str}
    ' f'
    {" · ".join(meta_parts)}
    ' f'
    ' ) pairs: list[str] = [] for o in buys: pairs.append(_row(o, 'buy')) for o in sells: pairs.append(_row(o, 'sell')) return f'
    {"".join(pairs)}
    ' def _buy_rank_badge(buy_amount: int) -> str: """매입 총액 기반 등급 뱃지. 100/200/500/1000만원 경계로 1~5등급. 막대 stack(군대 계급 모티프).""" if not buy_amount or buy_amount <= 0: return '' M = 10_000 if buy_amount <= 100 * M: tier = 1 elif buy_amount <= 200 * M: tier = 2 elif buy_amount <= 500 * M: tier = 3 elif buy_amount <= 1000 * M: tier = 4 else: tier = 5 stripes = ''.join('' for _ in range(tier)) title = f'매입 {buy_amount:,}원 · 등급 {tier}' return f'{stripes}' def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False, key_suffix: str = '') -> str: stock = html.escape(r['stock']) code = html.escape(r.get('code', '') or '') accounts = html.escape('+'.join(r.get('accounts', []))) qty = r.get('qty', 0) avg = r.get('avg', 0) price = r.get('price', 0) profit = r.get('profit', 0) profit_rate = r.get('profit_rate', 0.0) eval_value = r.get('eval_value', 0) buy_amount = r.get('buy_amount', 0) weight = (eval_value / total_value * 100) if total_value else 0.0 pcls = _profit_class(profit) sign = '+' if profit >= 0 else '' # 오늘 매매 별표 — 매수/매도 구분. 둘 다 발생 시 두 개 표시. star_parts: list[str] = [] if r.get('tdy_buyq'): star_parts.append(f'') if r.get('tdy_sellq'): star_parts.append(f'') star = ''.join(star_parts) day_change = r.get('day_change', 0) day_change_pct = r.get('day_change_pct', 0.0) candle_summary = '' ohlc = r.get('ohlc') or {} if ohlc: mini = _render_single_candle(ohlc.get('o', 0), ohlc.get('h', 0), ohlc.get('l', 0), ohlc.get('c', 0), width=24, height=35) if mini: candle_summary = f'
    {mini}
    ' j = r.get('journal') or {} journal_lines: list[str] = [] summary_journal_html = '' if j.get('buy_qty'): journal_lines.append( f'
    매수
    {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 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}%)
    ' ) # 가격 출처 마크 — cur_price 가 어느 시장 응답 가격과 일치하는지로 판정. # NXT 미거래 종목(ohlc_nxt 빈 dict 또는 NXT price==KRX price)은 KRX 마크. nxt_p = (r.get('ohlc_nxt') or {}).get('price', 0) krx_p = (r.get('ohlc_krx') or {}).get('price', 0) if nxt_p and nxt_p == price and nxt_p != krx_p: mark = 'NXT' else: mark = 'KRX' mark_html = f'{mark}' # OHLC mini-table — ka10095 NX/NXT batch 결과를 각 컬럼에 분리 표시. 활성 시장만 밝게. # _nxt_inactive 마커는 stock_portfolio_report의 NX 응답 기반 판정이라 ETF처럼 NXT 정상거래 종목도 # 잘못 잡힘 → 여기선 무시하고 실제 ohlc_nxt 데이터 유무로 표시 결정. # NXT 컬럼 baseline 을 어제 NXT 스냅샷 종가(r['pred_close']) 로 통일. # change/change_pct 도 스냅샷 baseline 기준으로 재계산 → cell 안 pct 표시도 일관. # 전 거래일 종가 행: KRX = pred_close_krx (KRX 전일종가), NXT = r['pred_close'] (어제 NXT 스냅샷). ohlc_krx_for_render = r.get('ohlc_krx') _pred_krx = r.get('pred_close_krx', 0) or 0 if ohlc_krx_for_render and _pred_krx > 0: ohlc_krx_for_render = dict(ohlc_krx_for_render) ohlc_krx_for_render['prev_close'] = _pred_krx ohlc_nxt_for_render = r.get('ohlc_nxt') _snap_prev = r.get('pred_close', 0) or 0 if ohlc_nxt_for_render and _snap_prev > 0: ohlc_nxt_for_render = dict(ohlc_nxt_for_render) ohlc_nxt_for_render['open'] = _snap_prev ohlc_nxt_for_render['prev_close'] = _snap_prev _nxt_px = ohlc_nxt_for_render.get('price', 0) or 0 ohlc_nxt_for_render['change'] = _nxt_px - _snap_prev ohlc_nxt_for_render['change_pct'] = ((_nxt_px - _snap_prev) / _snap_prev * 100) if _snap_prev else 0.0 ohlc_mini_html = _render_ohlc_mini_dual( ohlc_krx_for_render, ohlc_nxt_for_render, active_market=mark.lower(), ) # 미리보기 등락은 rebase 후 day_change_pct 사용 — 어제 NXT 종가 baseline. # raw pred_close_krx 는 kt00018 결함으로 정규장 시작 전·마감 후 cur_price 와 같은 값이 들어와 # 등락률이 항상 0% 으로 깔리는 문제가 있어 사용 X. d_pct_val = r.get('day_change_pct') if show_day_change and isinstance(d_pct_val, (int, float)) and d_pct_val != 0: d_sign = '+' if d_pct_val >= 0 else '' d_cls = 'day-pct up' if d_pct_val > 0 else 'day-pct down' price_html = f'{mark_html}{price:,}{d_sign}{d_pct_val:.2f}%' else: price_html = f'{mark_html}{price:,}' chart_block = '' if code: # 카드 펼침 이벤트에 클라이언트 JS 가 /api/chart_svg?code= 를 fetch 해 채움. # data-chart-code 속성으로 식별. panels swap 후 chartCache 복원도 같은 셀렉터로. chart_block = f'
    ' left_pairs = [ f'
    종목코드
    {code}
    ', f'
    현재가
    {price:,}원
    ', f'
    평단가
    {avg:,}원
    ', ] right_pairs = [ f'
    보유수량
    {qty:,}주
    ', f'
    매입금액
    {buy_amount:,}원
    ', f'
    평가금액
    {eval_value:,}원
    ', ] dl_inner = _interleave_kv_pairs(left_pairs, right_pairs) pl_pairs.extend(journal_lines) pl_dl_inner = ''.join(pl_pairs) row_key = (code or stock) + key_suffix tag_add_btn = _tag_add_button_html(r.get('code') or '', r.get('stock') or '') trade_btn = ( f'
    ' f'{tag_add_btn}' f'{_info_button_html(r.get("code") or "", r.get("stock") or "")}' f'' f'' f'
    ' ) if code else '' tag_chip = _tag_chip_html(r.get('code') or '', r.get('stock') or '', interactive=True) return f'''
    {_buy_rank_badge(buy_amount)}{stock}{tag_chip}{star}{accounts}
    보유 {qty:,}주{_pending_badges_html(r)}비중 {weight:.2f}%
    {summary_journal_html}
    {price_html}
    평단 {avg:,}
    {sign}{profit:,}{sign}{profit_rate:.2f}%
    {candle_summary}
    {dl_inner}
    {pl_dl_inner}
    {ohlc_mini_html} {_pending_detail_html(r)} {trade_btn} {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_buy_held_row(r: dict) -> str: """보유 종목 중 매수 미체결 걸린 행. 매수등록 섹션 (보유분) 카드. summary는 매수 대기 합계·평균 주문가, 보유 수량·평단도 함께. detail은 _pending_detail_html이 주문 단위 상세 출력.""" stock = html.escape(r['stock']) code = html.escape(r.get('code', '') or '') accounts = html.escape('+'.join(r.get('accounts', []))) qty = r.get('qty', 0) avg = r.get('avg', 0) price = r.get('price', 0) buy_qty = r.get('pending_buy_qty', 0) orders = [o for o in (r.get('pending_orders') or []) if o.get('side') == 'BUY'] priced = [o for o in orders if o.get('price', 0) > 0] if priced: total = sum(o['qty'] for o in priced) avg_buy = sum(o['qty'] * o['price'] for o in priced) // total if total else 0 else: avg_buy = 0 # 매수 주문 평균가 vs 현재가 → 추가매수 거리 if avg_buy and price: gap = price - avg_buy gap_pct = (gap / avg_buy * 100) if avg_buy else 0.0 gcls = 'up' if gap > 0 else ('down' if gap < 0 else 'neutral') gsign = '+' if gap >= 0 else '' diff_html = f'{gsign}{gap:,.0f}{gsign}{gap_pct:.2f}%' else: diff_html = '' price_html = f'{price:,}' if price else '-' summary_line2 = f'매수 대기 {buy_qty:,}주' if avg_buy: summary_line2 += f' · 평균 주문가 {avg_buy:,}원' if qty and avg: summary_line2 += f' · 보유 {qty:,}주 @ {avg:,}' summary_line2 += '' chart_block = '' if code: chart_block = f'
    ' detail_pairs: list[str] = [f'
    종목코드
    {code}
    '] if price: detail_pairs.append(f'
    현재가
    {price:,}원
    ') if avg: detail_pairs.append(f'
    평단가
    {avg:,}원
    ') if avg_buy: detail_pairs.append(f'
    주문 평균가
    {avg_buy:,}원
    ') if qty: detail_pairs.append(f'
    보유 수량
    {qty:,}주
    ') detail_pairs.append(f'
    매수 대기
    {buy_qty:,}주
    ') row_key = (code or stock) + ':pending-buy' return f'''
    {stock}매수등록{accounts}
    {summary_line2}
    {price_html}
    {f'
    {diff_html}
    ' if diff_html else ''}
    {''.join(detail_pairs)}
    {_pending_detail_html(r)} {chart_block}
    ''' def _render_pending_sell_held_row(r: dict) -> str: """보유 종목 중 매도 미체결 걸린 행. 매도등록 섹션 전용 카드. summary는 매도 대기 합계·평균 주문가, 보유 수량·평단도 함께. detail은 _pending_detail_html이 주문 단위 상세 출력.""" stock = html.escape(r['stock']) code = html.escape(r.get('code', '') or '') accounts = html.escape('+'.join(r.get('accounts', []))) qty = r.get('qty', 0) avg = r.get('avg', 0) price = r.get('price', 0) sell_qty = r.get('pending_sell_qty', 0) orders = [o for o in (r.get('pending_orders') or []) if o.get('side') == 'SELL'] priced = [o for o in orders if o.get('price', 0) > 0] if priced: total = sum(o['qty'] for o in priced) avg_sell = sum(o['qty'] * o['price'] for o in priced) // total if total else 0 else: avg_sell = 0 if avg_sell and avg: gap = avg_sell - avg gap_pct = (gap / avg * 100) if avg else 0.0 gcls = 'up' if gap > 0 else ('down' if gap < 0 else 'neutral') gsign = '+' if gap >= 0 else '' diff_html = f'{gsign}{gap:,.0f}{gsign}{gap_pct:.2f}%' else: diff_html = '' price_html = f'{price:,}' if price else '-' summary_line2 = f'매도 대기 {sell_qty:,}주' if avg_sell: summary_line2 += f' · 평균 주문가 {avg_sell:,}원' if qty and avg: summary_line2 += f' · 보유 {qty:,}주 @ {avg:,}' summary_line2 += '' chart_block = '' if code: chart_block = f'
    ' detail_pairs: list[str] = [f'
    종목코드
    {code}
    '] if price: detail_pairs.append(f'
    현재가
    {price:,}원
    ') if avg: detail_pairs.append(f'
    평단가
    {avg:,}원
    ') if avg_sell: detail_pairs.append(f'
    주문 평균가
    {avg_sell:,}원
    ') if qty: detail_pairs.append(f'
    보유 수량
    {qty:,}주
    ') detail_pairs.append(f'
    매도 대기
    {sell_qty:,}주
    ') row_key = (code or stock) + ':pending-sell' return f'''
    {stock}매도등록{accounts}
    {summary_line2}
    {price_html}
    {f'
    {diff_html}
    ' if diff_html else ''}
    {''.join(detail_pairs)}
    {_pending_detail_html(r)} {chart_block}
    ''' def _render_pending_unheld_row(r: dict) -> str: """미보유 + 미체결 매수 주문 종목. 보유·당일정산 아닌 신규 매수 대기 행. summary는 종목명·미체결 합계, detail은 _pending_detail_html이 주문 단위로 출력.""" stock = html.escape(r.get('stock') or r.get('code') or '') code = html.escape(r.get('code') or '') buy_qty = r.get('pending_buy_qty', 0) price = r.get('price') # 미체결 매수 평균가 (가중평균) — 시장가(price=0)는 제외 orders = [o for o in (r.get('pending_orders') or []) if o.get('side') == 'BUY'] priced = [o for o in orders if o.get('price', 0) > 0] if priced: total_qty = sum(o['qty'] for o in priced) avg_buy = sum(o['qty'] * o['price'] for o in priced) // total_qty if total_qty else 0 else: avg_buy = 0 accounts = sorted({o.get('account', '') for o in orders if o.get('account')}) acc_text = html.escape('+'.join(accounts)) if isinstance(price, (int, float)) and price: # 미리보기 등락은 KRX 전일종가 대비 — ka10095의 change_pct 그대로. day_pct = r.get('day_change_pct') if isinstance(day_pct, (int, float)): d_cls = 'day-pct up' if day_pct > 0 else ('day-pct down' if day_pct < 0 else 'day-pct neutral') d_sign = '+' if day_pct >= 0 else '' price_html = f'{price:,}{d_sign}{day_pct:.2f}%' else: price_html = f'{price:,}' if avg_buy: diff = price - avg_buy dpct = (diff / avg_buy * 100) if avg_buy else 0.0 dcls = 'up' if diff > 0 else ('down' if diff < 0 else 'neutral') dsign = '+' if diff >= 0 else '' diff_html = f'{dsign}{diff:,.0f}{dsign}{dpct:.2f}%' else: diff_html = '' else: price_html = '-' diff_html = '' summary_line2 = f'매수 대기 {buy_qty:,}주' if avg_buy: summary_line2 += f' · 평균 주문가 {avg_buy:,}원' summary_line2 += '' chart_block = '' if code: chart_block = f'
    ' detail_pairs: list[str] = [f'
    종목코드
    {code}
    '] if isinstance(price, (int, float)) and price: detail_pairs.append(f'
    현재가
    {price:,}원
    ') if avg_buy: detail_pairs.append(f'
    주문 평균가
    {avg_buy:,}원
    ') detail_pairs.append(f'
    매수 대기
    {buy_qty:,}주
    ') row_key = code or stock return f'''
    {stock}매수등록{acc_text}
    {summary_line2}
    {price_html}
    {f'
    {diff_html}
    ' if diff_html else ''}
    {''.join(detail_pairs)}
    {_pending_detail_html(r)} {chart_block}
    ''' def _aggregate_by_unit(series: list[tuple[str, int]], unit: str) -> list[tuple[str, int]]: """단위(day/week/month/year)별로 각 구간의 마지막 영업일 점만 남김. 'day'는 그대로 반환. week=ISO week (월~일), month=YYYY-MM, year=YYYY.""" if unit == 'day' or not series: return series from datetime import date as _date groups: dict = {} # group key → (date_str, value) for d, v in series: try: dt = _date.fromisoformat(d) except Exception: continue if unit == 'week': iso = dt.isocalendar() key = (iso[0], iso[1]) elif unit == 'month': key = (dt.year, dt.month) elif unit == 'year': key = (dt.year,) else: return series prev = groups.get(key) if prev is None or d > prev[0]: groups[key] = (d, v) return sorted(groups.values(), key=lambda x: x[0]) def _load_cash_flow_by_date(owner: str) -> dict[str, dict]: """portfolio_daily_snapshot에서 owner별 일자별 입출금 dict. {date: {'cash_in', 'cash_out'}} — 둘 다 0인 날은 제외. legacy 스냅샷(cash_in 키 없음)은 자연스럽게 0 처리. stock_portfolio_report가 매일 20:10 스냅샷 저장 시 누적. 적재 시작 이전 과거 데이터는 비어있음. """ if not SNAPSHOT_FILE.exists(): return {} try: snap = json.loads(SNAPSHOT_FILE.read_text()) except Exception: return {} out: dict[str, dict] = {} for date_key, day_snap in snap.items(): owner_snap = (day_snap or {}).get(owner) or {} ci = int(owner_snap.get('cash_in', 0) or 0) co = int(owner_snap.get('cash_out', 0) or 0) if ci or co: out[date_key] = {'cash_in': ci, 'cash_out': co} return out def _aggregate_cash_flows_by_unit(daily_cf: dict[str, dict], aggregated_dates: list[str], unit: str) -> dict[str, dict]: """unit 단위 집계 시 cash flow 를 series 점에 매핑. aggregated_dates: 차트 series 의 날짜 리스트 (호출 시점 시리즈). 'day' 또는 daily_cf 빈 dict면 그대로 반환. week/month/year: - 그룹에 series 점이 1개(=대표 영업일)면 그 그룹의 daily cash flow 들을 sum 해서 대표 점에 매핑. - 그룹에 series 점이 여러 개(=_expand_progress_group이 daily 로 펼친 진행 중 그룹)면 각 daily 점에 daily_cf 그대로 매핑 — sum 매핑 시 모든 daily 점이 동일 cf 로 박히는 버그 방지. """ if unit == 'day' or not daily_cf: return dict(daily_cf) if unit == 'day' else {d: dict(cf) for d, cf in daily_cf.items() if d in set(aggregated_dates)} from datetime import date as _date def _key(d: str): dt = _date.fromisoformat(d) if unit == 'week': iso = dt.isocalendar() return (iso[0], iso[1]) if unit == 'month': return (dt.year, dt.month) if unit == 'year': return (dt.year,) return None # 그룹별 daily_cf sum (대표 매핑용) group_sum: dict = {} for d, cf in daily_cf.items(): try: k = _key(d) except Exception: continue if k is None: continue s = group_sum.setdefault(k, {'cash_in': 0, 'cash_out': 0}) s['cash_in'] += cf.get('cash_in', 0) s['cash_out'] += cf.get('cash_out', 0) # series 의 그룹별 점 개수 — 1점=대표, 2점 이상=daily 펼쳐짐. series_by_group: dict = {} for d in aggregated_dates: try: k = _key(d) except Exception: continue if k is None: continue series_by_group.setdefault(k, []).append(d) out: dict[str, dict] = {} for k, dates in series_by_group.items(): if len(dates) == 1: s = group_sum.get(k) if s and (s['cash_in'] or s['cash_out']): out[dates[0]] = {'cash_in': s['cash_in'], 'cash_out': s['cash_out']} else: for d in dates: cf = daily_cf.get(d) if cf and (cf.get('cash_in', 0) or cf.get('cash_out', 0)): out[d] = {'cash_in': cf.get('cash_in', 0), 'cash_out': cf.get('cash_out', 0)} return out def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: str = 'day', with_prev_baseline: bool = False): """portfolio_daily_snapshot에서 owner별 순자산 시계열을 추출. 각 날짜의 net_worth = sum(holdings[*].eval_value) + deposit. 누락 필드는 0으로 처리. unit으로 일/주/월/연 단위 집계 (각 구간 마지막 영업일 점). 기간(days) 필터는 단위 집계 후 적용. with_prev_baseline=True 면 (series, prev_baseline) 튜플 반환. prev_baseline 은 PnL 모드용으로 series 시작점 직전의 (date, nw) 점 — series[0] 손익도 표시되도록 _to_pnl_series 에 baseline 으로 넘긴다. series 가 이미 baseline 점을 prepend 한 케이스(기간 잘림 fallback, 진행중 그룹 fallback)에선 None. """ if not SNAPSHOT_FILE.exists(): return ([], None) if with_prev_baseline else [] try: snap = json.loads(SNAPSHOT_FILE.read_text()) except Exception as e: sys.stderr.write(f'snapshot load failed: {e}\n') return ([], None) if with_prev_baseline else [] daily_series: list[tuple[str, int]] = [] for date_key in sorted(snap.keys()): owner_snap = (snap.get(date_key) or {}).get(owner) or {} # v3/v4: {'holdings': {...}, 'deposit': int}, v1 legacy flat: {종목: {qty, ...}} (holdings/deposit 키 없음). if 'holdings' in owner_snap: holdings = owner_snap.get('holdings') or {} deposit = int(owner_snap.get('deposit', 0) or 0) else: holdings = {k: v for k, v in owner_snap.items() if isinstance(v, dict) and 'eval_value' in v} deposit = 0 if not holdings and not deposit: continue eval_total = sum(int(v.get('eval_value', 0) or 0) for v in holdings.values()) daily_series.append((date_key, eval_total + deposit)) aggregated = _aggregate_by_unit(daily_series, unit) range_start: str | None = None # 기간 필터의 시작 경계 — fallback도 이 안에서만 # filtered 에 baseline 점이 prepend 되었으면 그게 series[0] = PnL baseline 역할 → prev_baseline 별도 안 넘김 baseline_prepended = False if days == -2: # 이번달 — KST 기준 이번 달 1일 이후만 prefix = datetime.now(KST).strftime('%Y-%m') range_start = f'{prefix}-01' filtered = [(d, v) for d, v in aggregated if d.startswith(prefix)] elif days == -3: # 이번주 — KST 기준 이번 ISO 월요일~오늘 today = datetime.now(KST).date() monday = (today - timedelta(days=today.weekday())).isoformat() range_start = monday filtered = [(d, v) for d, v in aggregated if d >= monday] elif days == -4: # 올해 — KST 기준 올해 1월 1일 이후 prefix = datetime.now(KST).strftime('%Y') range_start = f'{prefix}-01-01' filtered = [(d, v) for d, v in aggregated if d.startswith(prefix)] elif days < 0: # 전체 — 진행 그룹 daily fallback만 적용 (range 무한) filtered = list(aggregated) else: filtered = list(aggregated[-days:]) # 첫날이 filter 시작점에 막 들어와 1점만 잡힌 케이스 — 직전 영업일 점을 baseline으로 prepend. # (이번주 월요일·이번달 1일·올해 1월 1일 등에 "데이터 부족" 안내 대신 비교선이 잡히도록.) # filtered 가 아예 빈 경우(이번주 시작이 휴장·당일 snapshot 적재 전)에도 boundary 기준 직전 점을 # 1개 들고 와야 caller 의 라이브 current_net 과 합쳐 2점 차트가 그려진다. if len(filtered) < 2: boundary = filtered[0][0] if filtered else (range_start or '9999-99-99') baseline = [s for s in aggregated if s[0] < boundary] if baseline: if filtered: filtered = [baseline[-1]] + filtered else: filtered = [baseline[-1]] baseline_prepended = True # unit이 주/월/연이고도 여전히 1점이면 진행 중 그룹의 daily 점들로 풀어줌. # (예: 올해+월별 → 5월만 1점 → 5/01~5/18 일별 점들 + 직전 daily 점 baseline) # range_start로 fallback 범위를 기간 필터 안으로 제한 — 이번주+월별이 5월 전체로 새지 않음. expanded_raw = _expand_progress_group(filtered, daily_series, unit, range_start=range_start) # day 단위 차트는 휴장일을 display 시리즈에서 제외 — 시장 무변동이라 점 표시가 무의미. # baseline 후보(aggregated)엔 그대로 남겨 휴장일의 net_worth 가 직전 baseline 으로 활용 가능 (예: 5/1 → 5/4 손익). holidays = _load_holidays() if unit == 'day' else set() expanded = [s for s in expanded_raw if s[0] not in holidays] if holidays else expanded_raw if not with_prev_baseline: return expanded # _expand_progress_group 가 baseline 1점 prepend 한 케이스 — expanded_raw[0] 가 filtered[0] 보다 앞이면 prepend 발생 expand_prepended = bool(expanded_raw and filtered and expanded_raw[0][0] != filtered[0][0]) if baseline_prepended or expand_prepended or not expanded: return expanded, None # 정상 case (기간 필터 내 2점 이상) — expanded[0] 직전의 aggregated 점이 PnL baseline first_dt = expanded[0][0] prev_candidates = [s for s in aggregated if s[0] < first_dt] if prev_candidates: return expanded, prev_candidates[-1] # 직전 그룹 데이터 없을 때 fallback: # - day 모드: first_dt holdings 의 buy_total + deposit (=owner 첫 적재일 누적평가손익 baseline) # - month/year/week 그룹 1개뿐: 그 그룹 안의 첫 영업일 daily 점 (그룹 안 시작→끝 변동 표시). # ex) 5월만 있는데 본인 4월 empty → 5월 그룹 대표 1점 + 5/1 baseline 으로 한달 추세 그림. # 2점 이상일 땐 적용 X — series 첫 그룹 대표가 이미 baseline 역할. if unit == 'day': first_snap = (snap.get(first_dt) or {}).get(owner) or {} first_holdings = first_snap.get('holdings') or {} buy_total = sum(int(h.get('qty', 0) or 0) * int(h.get('avg_price', 0) or 0) for h in first_holdings.values()) first_deposit = int(first_snap.get('deposit', 0) or 0) if buy_total > 0: return expanded, (first_dt, buy_total + first_deposit) elif len(expanded) == 1: from datetime import date as _date only_dt = _date.fromisoformat(first_dt) if unit == 'month': group_start = f'{only_dt.year:04d}-{only_dt.month:02d}-01' elif unit == 'week': group_start = (only_dt - timedelta(days=only_dt.weekday())).isoformat() elif unit == 'year': group_start = f'{only_dt.year:04d}-01-01' else: group_start = None if group_start: group_first = next((s for s in daily_series if group_start <= s[0] < first_dt), None) if group_first: return expanded, group_first return expanded, None def _to_pnl_series(series: list[tuple[str, int]], cf_by_date: dict | None, prev_baseline_nw: int | None = None) -> tuple[list[tuple[str, int]], int, int]: """net_worth 시계열 → '시작 baseline=0' 누적손익 시계열로 변환. cumulative_pnl[i] = net_worth[i] - net_worth[start] - sum(net_cash_flow[after start..i]). 첫 점은 baseline 이므로 cf 제외(이미 nw[start] 에 반영). cf_by_date 는 series 와 동일 unit 으로 이미 집계된 {date: {cash_in, cash_out}}. prev_baseline_nw 가 주어지면 series 시작점 직전 영업일의 net_worth 를 baseline 으로 잡아 series[0] 의 손익도 표시된다 (이번달/이번주 기간 필터로 첫날 손익이 baseline=0 으로 깔리는 문제 해결). cf_by_date 도 series[0] 의 cash flow 부터 누적에 포함된다. Returns: (pnl_series, baseline_nw, cumulative_cf_total) — current 점 변환에 baseline/cf 필요. """ if not series: return [], 0, 0 cf = cf_by_date or {} if prev_baseline_nw is not None: baseline_nw = int(prev_baseline_nw) cumulative_cf = 0 out: list[tuple[str, int]] = [] for d, nw in series: delta = cf.get(d) or {} cumulative_cf += int(delta.get('cash_in', 0) or 0) - int(delta.get('cash_out', 0) or 0) out.append((d, int(nw) - baseline_nw - cumulative_cf)) else: baseline_nw = int(series[0][1]) cumulative_cf = 0 out = [(series[0][0], 0)] for d, nw in series[1:]: delta = cf.get(d) or {} cumulative_cf += int(delta.get('cash_in', 0) or 0) - int(delta.get('cash_out', 0) or 0) out.append((d, int(nw) - baseline_nw - cumulative_cf)) return out, baseline_nw, cumulative_cf def _expand_progress_group(filtered: list[tuple[str, int]], daily_series: list[tuple[str, int]], unit: str, *, range_start: str | None = None) -> list[tuple[str, int]]: """unit이 주이고 series가 1점뿐이면 그 진행 중 그룹의 daily 점들로 대체. range_start가 주어지면 group_start를 그것보다 뒤로 제한 (기간 필터와 교집합). 그룹 직전 daily 점 1개를 baseline으로 prepend (있으면). month/year 단위는 daily 펼침 제외 — 단위 의도와 안 맞음. 그룹 1개뿐이면 prev_baseline fallback 으로 그룹 첫 영업일을 baseline 으로 잡아 그룹 안 시작→끝 변동을 2점 차트로 표시.""" if unit in ('day', 'month', 'year') or len(filtered) >= 2 or not filtered or not daily_series: return filtered from datetime import date as _date only_date = filtered[0][0] only_dt = _date.fromisoformat(only_date) if unit == 'week': group_start = (only_dt - timedelta(days=only_dt.weekday())).isoformat() else: return filtered if range_start and range_start > group_start: group_start = range_start in_group = [s for s in daily_series if group_start <= s[0] <= only_date] if len(in_group) < 2: return filtered before_group = [s for s in daily_series if s[0] < group_start] baseline = before_group[-1:] if before_group else [] return baseline + in_group def _render_net_worth_chart(series: list[tuple[str, int]], current_net: int | None = None, selected_days: int = NET_WORTH_CHART_DAYS, selected_unit: str = NET_WORTH_CHART_UNIT, cash_flow_by_date: dict | None = None, today_cash_flow: dict | None = None, mode: str = NET_WORTH_CHART_MODE, prev_baseline_point: tuple[str, int] | None = None) -> str: """꺾은선 SVG (순자산 / 손익누적 공용). 외부 의존성 없음. 데이터 < 2점이면 안내문 반환. mode='net'(기본): series 는 raw 순자산 시계열. 입출금 마커·툴팁 행 표시. mode='pnl': series 는 _to_pnl_series 결과(첫 점=0 누적손익). 입출금 효과는 이미 제거, 마커·툴팁 행 모두 숨김. 헤더 라벨도 '손익누적' 으로 분기. current_net 은 mode에 맞춰 caller가 변환해서 넘긴다(net=순자산, pnl=누적손익). selected_unit (day/week/month/year)에 따라 X축 라벨 형식과 tooltip 손익 라벨이 분기된다. prev_baseline_point=(date, nw) 가 주어지면 차트 series 앞에 baseline 점 prepend → 첫 영업일 변동이 0 기점에서 분기되는 모양으로 시각화. 그 baseline ↔ 첫 영업일 구간은 점선. """ # pnl·period 모두 '입출금 제거 누적손익' 시계열을 입력으로 받는다(cf 마커·순자산 행 숨김 공통). # pnl=전부 누적 곡선, period=각 점이 그 기간(일/주/월/연) 자체 손익. is_pnl = mode in ('pnl', 'period') per_period = (mode == 'period') cash_flow_by_date = cash_flow_by_date or {} def _pp_key(ds: str): from datetime import date as _pp_date try: dt = _pp_date.fromisoformat(ds) except Exception: return None if selected_unit == 'week': iso = dt.isocalendar() return (iso[0], iso[1]) if selected_unit == 'month': return (dt.year, dt.month) if selected_unit == 'year': return (dt.year,) return ds # day — 각 날짜가 독립 기간 # baseline 점 prepend — PnL 모드면 baseline 값 = 0 (series 자체가 baseline 대비 누적이라). # net 모드면 prev nw 그대로. prev_baseline_nw = prev_baseline_point[1] if prev_baseline_point else None # day/total 계산용 baseline_prepended_pt: tuple[str, int] | None = None if prev_baseline_point and series: prev_dt, prev_nw = prev_baseline_point prev_v_for_chart = 0 if is_pnl else int(prev_nw) baseline_prepended_pt = (prev_dt, prev_v_for_chart) # 기간·단위·모드 토글은 자산정보 탭 상단의 공통 select 컨트롤로 이동 (_render_chart_controls). # 차트 wrapper엔 data-chart-days/unit/mode 박아 swap 후 상태 인식 가능. if selected_unit == 'month': empty_msg = '월별 차트는 2개월 이상의 데이터가 필요합니다.' elif selected_unit == 'week': empty_msg = '주별 차트는 2주 이상의 데이터가 필요합니다.' elif selected_unit == 'year': empty_msg = '연별 차트는 2년 이상의 데이터가 필요합니다.' else: _empty_metric = '기간손익' if per_period else ('손익누적' if is_pnl else '순자산') empty_msg = f'최근 {_empty_metric} 추이 데이터가 부족합니다.' def _empty(msg: str) -> str: return ( f'
    ' f'
    {msg}
    ' '
    ' ) if len(series) < 2 and current_net is None: return _empty(empty_msg) # stock.briefing 정규 적재 시각(20:10 KST) 이전이면 오늘 snapshot 행은 장중 추정치. # series 끝 행을 제거하고 live 점으로 교체 → 마지막 구간 점선 분기 보존. # 마감 후엔 confirmed로 간주, snapshot 오늘 행 유지 → 일반 실선. now = datetime.now(KST) today_kst = now.strftime('%Y-%m-%d') before_close = (now.hour, now.minute) < (20, 10) series = list(series) # 외부 인자 mutate 방지 + 로컬 컷 if before_close and series and series[-1][0] == today_kst: series = series[:-1] has_live = ( current_net is not None and (not series or series[-1][0] != today_kst) ) # 휴장일/주말은 시장 변동이 없어야 자연 — NXT 가격 보정으로 인한 가짜 점프(=오늘 라이브 점이 어제 스냅샷과 차이) 차단. if has_live and _market_phase_state().get('phase') in ('holiday', 'weekend'): has_live = False # per_period + 라이브 점이 있으면, 라이브와 같은 기간의 확정 점들은 중복이라 제거 (라이브가 그 기간 대표). # 예: 연별인데 2026 연 대표점(5/29) + 라이브(6/01) 둘 다 2026 → 5/29 버려 baseline→현재 2점으로 그림. if per_period and has_live and series: _live_key = _pp_key(today_kst) while series and _pp_key(series[-1][0]) == _live_key: series = series[:-1] # baseline 점이 있으면 series 앞에 prepend (시각화용). day/total tooltip 도 자연스레 baseline 기준이 됨. has_baseline = baseline_prepended_pt is not None full_series_with_baseline = ([baseline_prepended_pt] if has_baseline else []) + series full = full_series_with_baseline + ([(today_kst, int(current_net))] if has_live else []) if len(full) < 2: return _empty(empty_msg) values = [v for _, v in full] # per_period 라인 = '기간별 손익'(그 기간 자체 손익). values(누적)는 tooltip '누적 손익'·헤더 합계용 유지. # 각 점 = 그 점 누적값 − 직전 '다른 기간' 점의 누적값 (없으면 baseline 0). # 직전 '다른 기간' 기준이라, 같은 기간 점이 여러 개여도(올해+월별 진행 중 등) 그 기간 전체 손익이 잡힌다. # 단일 기간(예: 데이터가 2026 한 해뿐)이면 위에서 라이브 중복점을 정리해 baseline(0)→현재 2점으로 그린다. if per_period: _pp_keys = [_pp_key(d) for d, _ in full] plot_values = [] for _i in range(len(values)): _base = 0 for _j in range(_i - 1, -1, -1): if _pp_keys[_j] != _pp_keys[_i]: _base = values[_j] break plot_values.append(int(values[_i]) - _base) else: plot_values = values vmin, vmax = min(plot_values), max(plot_values) vrange = max(vmax - vmin, 1) pad = vrange * 0.10 y_lo = vmin - pad y_hi = vmax + pad span = max(y_hi - y_lo, 1) # viewBox 800x280, padding: 좌52 우16 상14 하34 (날짜라벨 + 상단 cf 라벨 영역). W, H = 800, 360 L, R, T, B = 52, 16, 14, 34 iw = W - L - R ih = H - T - B n = len(full) def x_at(i: int) -> float: return L + (i / (n - 1)) * iw if n > 1 else L + iw / 2 def y_at(v: int) -> float: return T + (1 - (v - y_lo) / span) * ih pts = [(x_at(i), y_at(v)) for i, v in enumerate(plot_values)] # 3-way path 분리: # - baseline_dashed_pts: baseline → 첫 영업일 (있는 경우) # - solid_pts: 첫 영업일 → 마지막 확정 snapshot # - dashed_pts: 마지막 snapshot → live 점 (있는 경우) base_offset = 1 if has_baseline else 0 solid_end = len(full) - (1 if has_live else 0) baseline_dashed_pts = pts[:base_offset + 1] if has_baseline else [] solid_pts = pts[base_offset:solid_end] dashed_pts = pts[solid_end - 1:] if has_live else [] # per_period면 헤더·라인색을 '마지막 기간 손익' 기준으로 (그 외 pnl/net은 시작 대비 변동). delta = plot_values[-1] if per_period else values[-1] - values[0] up = delta >= 0 line_color = '#ff4d5e' if up else '#4a8cf0' fill_id = f'nw-grad-{"up" if up else "dn"}' delta_sign = '+' if delta >= 0 else '−' if is_pnl: # per_period=마지막 기간 손익, 아니면 마지막 누적 손익. (pnl 첫 점=0 baseline → 비율 무의미) _hdr_val = plot_values[-1] if per_period else values[-1] delta_label = f'{delta_sign}{abs(_hdr_val):,}원' else: delta_pct = (delta / values[0] * 100) if values[0] else 0.0 delta_label = f'{delta_sign}{abs(delta):,}원 ({delta_sign}{abs(delta_pct):.2f}%)' delta_class = 'up' if up else 'down' # 데이터 범위(vmin~vmax)를 3구간으로 균등 분할 → 가로선 4개 (vmax / +2/3 / +1/3 / vmin). grid_vals = [vmax - vrange * i / 3 for i in range(4)] grid_lines = [] for gv in grid_vals: gy = y_at(int(gv)) grid_lines.append(f'') label = f'{int(gv) // 1_000_000:,}M' if abs(gv) >= 1_000_000 else f'{int(gv) // 1000:,}K' grid_lines.append(f'{html.escape(label)}') # 0원 기준선 — 손익 0 높이가 차트 범위 안일 때만 (어디부터 적자/흑자인지 표시). 순자산은 항상 양수라 자동 미표시. # 기준선(가로선)은 유지하고 '0' 텍스트 라벨만 제거 (관리자님 요청 — M/K 라벨과 겹쳐 보기 번잡). zero_line = '' if y_lo <= 0 <= y_hi: zy = y_at(0) zero_line = ( f'' ) label_idx = sorted({0, n // 2, n - 1}) # 점들이 모두 같은 그룹(month/year)이면 라벨 중복이라 한 단계 작은 단위로 fallback. # 라벨 형식은 unit 그대로 — year=YYYY, month=YYYY-MM, week/day=MM-DD. # 한 단위 안에 모든 점이 들어가는 케이스(이번달·올해 진행 중)도 unit 라벨 유지 → 중복 라벨 보일 수 있으나 사용자 의도(단위 명확 표시) 우선. def _x_label_for(date_iso: str) -> str: if selected_unit == 'year': return date_iso[:4] if selected_unit == 'month': return date_iso[:7] return date_iso[5:] x_labels = [] for i in label_idx: date_str = _x_label_for(full[i][0]) if has_live and i == n - 1: date_str += '*' # 장중 잠정 표시 x_labels.append( f'{html.escape(date_str)}' ) last_x, last_y = pts[-1] # per_period면 마지막 점 = 마지막 기간 손익(=마지막 dot 높이와 일치). 아니면 누적 마지막값. last_v = plot_values[-1] if per_period else values[-1] if is_pnl: last_sign = '+' if last_v >= 0 else '−' last_label_v = f'{last_sign}{abs(last_v):,}원' else: last_label_v = f'{last_v:,}원' if has_live: last_label_v += ' (장중)' range_label = f'{_x_label_for(full[0][0])} ~ {_x_label_for(full[-1][0])}' # per_period는 baseline 제외 실제 표시 기간 수(라이브 포함). 라이브 중복점 제거로 series가 비어도 정확. if per_period: title_n = len({k for k in _pp_keys[base_offset:] if k is not None}) else: title_n = len(series) _unit_word = {'week': '주', 'month': '월', 'year': '연'}.get(selected_unit, '영업일') _title_metric = '손익' if per_period else ('손익누적' if is_pnl else '순자산') title = f'최근 {title_n}{_unit_word} {_title_metric}' + (' · 오늘 잠정' if has_live else '') # 영역 채우기는 종가 구간만 (실선 영역). 점선 구간은 area 미적용. area_d = '' if len(solid_pts) >= 2: area_d = ( f'M {solid_pts[0][0]:.2f},{T + ih:.2f} ' + 'L ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in solid_pts) + f' L {solid_pts[-1][0]:.2f},{T + ih:.2f} Z' ) solid_d = '' if len(solid_pts) >= 2: solid_d = 'M ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in solid_pts) dashed_d = '' if len(dashed_pts) >= 2: dashed_d = 'M ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in dashed_pts) baseline_dashed_d = '' if len(baseline_dashed_pts) >= 2: baseline_dashed_d = 'M ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in baseline_dashed_pts) last_dot_class = 'last-dot live' if has_live else 'last-dot' # 호버/터치 인터랙션용 메타. 각 포인트의 viewBox 좌표·날짜·순자산·그날 손익·누적 손익·입출금. base_v = values[0] prev_v: int | None = None pts_meta: list[dict] = [] today_kst_str = today_kst # 위에서 산출됨 for i, (d_str, v_i) in enumerate(full): x_i, y_i = pts[i] cf = cash_flow_by_date.get(d_str) # 오늘 라이브 포인트는 별도 today_cash_flow 우선 — snapshot 적재 전이라도 KPI와 일치하게. if d_str == today_kst_str and today_cash_flow and (today_cash_flow.get('cash_in', 0) or today_cash_flow.get('cash_out', 0)): cf = {'cash_in': int(today_cash_flow.get('cash_in', 0) or 0), 'cash_out': int(today_cash_flow.get('cash_out', 0) or 0)} # PnL 모드: v 자체가 이미 baseline 대비 누적손익 → total=v_i (재차감 X). # day 는 직전 점 대비 변동분이고, 첫 점은 baseline 대비라 v_i 자체가 첫날 손익 의미 (0 아님). # net 모드: v 가 raw 순자산. prev_baseline_nw 가 있으면 첫 점 day/total 도 그 대비 (휴장일 baseline 등). if is_pnl: # per_period면 '그 기간 손익' 행을 plot_values 와 일치시킨다(라이브점=이번 달 전체 손익). # 아니면(day) 직전 점 대비 변동분. total 은 항상 누적값. day_val = int(plot_values[i]) if per_period else (int(v_i - prev_v) if prev_v is not None else int(v_i)) total_val = int(v_i) else: if prev_v is not None: day_val = int(v_i - prev_v) elif prev_baseline_nw is not None: day_val = int(v_i - prev_baseline_nw) else: day_val = 0 total_val = int(v_i - prev_baseline_nw) if prev_baseline_nw is not None else int(v_i - base_v) meta = { 'd': d_str, 'v': int(v_i), 'day': day_val, 'total': total_val, 'x': round(x_i, 2), 'y': round(y_i, 2), } if cf and (cf.get('cash_in', 0) or cf.get('cash_out', 0)): ci_i = int(cf.get('cash_in', 0) or 0) co_i = int(cf.get('cash_out', 0) or 0) meta['cf_in'] = ci_i meta['cf_out'] = co_i meta['cf_net'] = ci_i - co_i pts_meta.append(meta) prev_v = v_i pts_attr = html.escape(json.dumps(pts_meta, ensure_ascii=False), quote=True) # 입출금 수직선 — 해당 포인트 x좌표에 차트 영역을 가로지르는 dashed 선. # 색상: net > 0 입금 우세 = #0a7 / net < 0 출금 우세 = #d33 / 0 (= 입+출 동일) = #888. # 작은 캡션 라벨(±N) 을 차트 상단에 살짝 띄움 — 텍스트로 명확히 표시. cf_markers: list[str] = [] for meta in pts_meta: if 'cf_net' not in meta: continue cf_x = meta['x'] cf_net = meta['cf_net'] cf_color = '#0a7' if cf_net > 0 else ('#d33' if cf_net < 0 else '#888') cf_sign = '+' if cf_net >= 0 else '−' cf_abs = abs(cf_net) cf_label = f'{cf_sign}{cf_abs // 1_000_000}M' if cf_abs >= 1_000_000 else ( f'{cf_sign}{cf_abs // 1000}K' if cf_abs >= 1000 else f'{cf_sign}{cf_abs}' ) cf_markers.append( f'' ) cf_markers.append( f'{html.escape(cf_label)}' ) # per_period: 각 기간 점마다 세로 구분선 + 작은 마커 — 기간별 손익이 비슷해 라인이 평평해도 기간 경계를 읽을 수 있게. # (누적/순자산은 연속 곡선이라 구분선 불필요 — period 모드만) period_seps = [] point_dots = [] for _i, (px, py) in enumerate(pts): if per_period: # 구분선은 기간손익 전용. 점 색 = 그 기간 손익 부호 (이익=빨강 / 손실=파랑 / 0=회색). period_seps.append( f'' ) _pv = plot_values[_i] _dot_color = '#ff4d5e' if _pv > 0 else ('#4a8cf0' if _pv < 0 else '#6b7280') else: # 순자산·손익누적은 연속 곡선 — 점만 라인색으로 (부호색은 순자산이 항상 양수라 무의미). _dot_color = line_color point_dots.append(f'') svg_parts = [ f'', '' f'' f'' f'' '' '', ''.join(grid_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 — 그려진 라인(plot_values) 의 max 시점에 노란 마커. 마지막 포인트와 같으면 last_dot 으로 충분해 생략. peak_i = max(range(len(plot_values)), key=lambda i: plot_values[i]) if peak_i != n - 1: peak_x, peak_y = pts[peak_i] svg_parts.append( f'' ) # 신고가 마커 — 오늘(마지막) 그려진 값이 이전 모든 포인트를 strict 하게 초과하면 last dot 위에 금색 ring + 라벨. # per_period면 '역대 최고 기간 손익', 아니면 누적/순자산 신고가. if n >= 2 and plot_values[-1] > max(plot_values[:-1]): 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'' ) for r in sorted(pending_buy_held, key=lambda x: -x.get('pending_buy_qty', 0)): parts.append(_render_pending_buy_held_row(r)) for r in pending_unheld: parts.append(_render_pending_unheld_row(r)) pending_sell_held = [r for r in (d.get('consolidated') or []) if not r.get('phantom') and r.get('pending_sell_qty', 0) > 0] if pending_sell_held: parts.append( f'' ) for r in sorted(pending_sell_held, key=lambda x: -x.get('pending_sell_qty', 0)): parts.append(_render_pending_sell_held_row(r)) # 당일매매 발생한 종목도 보유 종목 섹션에 그대로 표시 — 양쪽 다 보이게 함. # 같은 종목이 양쪽에 들어가니 row_key에 ':held' suffix를 붙여 mutex/open 복원 충돌 방지. # 합산 / 각계좌별도 토글: # - 합산: consolidated 행 (현재 동작 유지) # - 각계좌별도: raw rows를 평가금액 desc로 평탄 정렬, account 라벨은 각 행에 표시 # 토글은 자산정보 탭과 in-memory state 공유 → 한쪽 토글하면 다른 쪽도 같이 바뀜. raw_rows = d.get('rows') or [] # ohlc 같은 owner-level annotation을 raw row에 코드 단위로 전파. ohlc_by_code = { c.get('code'): c.get('ohlc') for c in d.get('consolidated', []) if c.get('code') and c.get('ohlc') } if held or raw_rows: consolidated_rows_html: list[str] = [] for r in sorted(held, key=lambda x: -x.get('eval_value', 0)): consolidated_rows_html.append( _render_holding_row(r, d['total_value'], show_day_change, key_suffix=':held') ) by_account_rows_html: list[str] = [] for r in sorted(raw_rows, key=lambda x: -x.get('eval_value', 0)): if r.get('qty', 0) <= 0: continue synth = dict(r) synth['accounts'] = [r.get('account', '')] synth['traded_today'] = (r.get('tdy_buyq', 0) + r.get('tdy_sellq', 0)) > 0 if r.get('code') in ohlc_by_code: synth['ohlc'] = ohlc_by_code[r['code']] acc_suffix = (r.get('account', '') or '').replace(':', '_') by_account_rows_html.append( _render_holding_row(synth, d['total_value'], show_day_change, key_suffix=f':held:{acc_suffix}') ) held_count = len(held) acc_count = sum(1 for r in raw_rows if r.get('qty', 0) > 0) # 토글 버튼은 owner KPI 카드 상단 (전체 거래내역 옆)으로 이동 — panes만 여기에 prerender. parts.append( '
    ' '' '' '
    ' ) if not held and not phantoms: parts.append('
    보유 종목 데이터가 없습니다.
    ') return '\n'.join(parts) _CSS = ''' * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } html, body { margin: 0; padding: 0; overscroll-behavior-y: none; touch-action: manipulation; } body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Apple SD Gothic Neo", sans-serif; background: #0b0d12; color: #e6e6e6; -webkit-font-smoothing: antialiased; padding-bottom: calc(env(safe-area-inset-bottom) + 28px); -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } /* 하단 고정 시장 지수 — 코스피/코스닥. viewport 최하단에 박힘. iOS 홈 인디케이터 영역만 safe-area 패딩으로 양보. */ /* 증권사 스타일 무한 가로 스크롤. 같은 항목 세트를 2번 렌더 후 -50% 이동 → seamless loop. */ .market-ticker { position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; background: rgba(11,13,18,0.96); backdrop-filter: saturate(160%) blur(10px); -webkit-backdrop-filter: saturate(160%) blur(10px); border-top: 1px solid #1f2330; padding: 5px 0; padding-bottom: calc(5px + env(safe-area-inset-bottom)); font-size: 10.5px; line-height: 1.2; font-variant-numeric: tabular-nums; color: #e6e6e6; overflow: hidden; white-space: nowrap; } .ticker-track { display: inline-flex; width: max-content; /* intro(5s linear): viewport 오른쪽 끝(100vw)에서 자연 위치(0)까지 일정 속도로 슬라이드 인 — 첫 로딩 시 KOSPI 잘림 방지. ease-out은 시작 순간이 peak 속도라 "엄청 빠름" 인상을 줘서 linear로 고정. scroll(20s linear infinite, 5s 지연): intro 끝난 직후 무한 가로 스크롤로 인계. translateX(0)에서 매끄럽게 연결. */ animation: ticker-intro 5s linear, ticker-scroll 20s linear 5s infinite; will-change: transform; } .ticker-set { display: inline-flex; align-items: center; gap: 20px; flex-shrink: 0; } .ticker-sep { display: inline-flex; align-items: center; padding: 0 22px; color: #4a5060; font-size: 14px; font-weight: 700; user-select: none; } @keyframes ticker-intro { from { transform: translateX(100vw); } to { transform: translateX(0); } } @keyframes ticker-scroll { from { transform: translateX(0); } to { transform: translateX(-50%); } } /* PC 마우스 호버 시만 일시정지. 모바일 :active는 페이지 터치 후 잔류해서 ticker가 멈춰버리는 이슈로 제외. */ @media (hover: hover) { .market-ticker:hover .ticker-track { animation-play-state: paused; } } .market-ticker .idx { display: inline-flex; align-items: baseline; gap: 4px; font-size: 10.5px; } .market-ticker .idx-label { color: #8b8f9a; font-weight: 500; font-size: 10.5px; } .market-ticker .idx-price { color: #f0f0f0; font-weight: 500; font-size: 10.5px; } .market-ticker .idx-delta { font-weight: 400; font-size: 10.5px; } .market-ticker .idx-delta.up { color: #ff4d5e; } .market-ticker .idx-delta.down { color: #4d9bff; } .market-ticker .idx-delta.flat { color: #8b8f9a; } .market-ticker .idx-empty { color: #5a5f6c; font-size: 10px; padding: 0 14px; } /* ── pull-to-refresh ── */ /* .page가 손가락 따라 translateY로 내려감 → topbar+컨텐츠 다 함께 내려가고 그 위로 빈공간 노출. .page.dragging 일 때만 topbar sticky를 일시 해제(position:static)해서 wrapper 변환에 묶이게 함. 평상시(non-dragging)엔 topbar 그대로 sticky → 정상 스크롤 시 헤더 박혀있음. .ptr은 .page 내부 absolute top:-50 → 평소엔 viewport 위에 숨어있다가 .page 내려가면 갭에 emerge. */ .page { position: relative; background: #0b0d12; transition: transform 0.32s cubic-bezier(0.2, 0.8, 0.2, 1); } /* will-change: transform이 항상 켜져있으면 .page가 containing block이 되어 자식 .topbar의 position:sticky가 viewport가 아닌 .page 좌표계에 갇혀 스크롤을 따라 올라간다. 드래그 시점에만 promote — GPU 가속이 필요한 순간에만 적용. */ .page.dragging { transition: none; will-change: transform; } .page.dragging .topbar { position: static; } .ptr { position: absolute; /* 노치에선 env(safe-area-inset-top)만큼 더 아래로 내려서 시계·카메라 영역 회피 */ top: calc(-52px + env(safe-area-inset-top, 0px)); left: 50%; width: 40px; height: 40px; margin-left: -20px; background: rgba(28, 34, 48, 0.95); border: 1px solid #2a3142; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: #8b8f9a; opacity: 0; pointer-events: none; z-index: 25; /* topbar(20)보다 위 — ptr이 topbar 영역 위로 떠올라 가려지지 않음 */ font-variant-numeric: tabular-nums; transition: color 0.18s, border-color 0.18s, background 0.18s, box-shadow 0.2s; box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } .ptr .arrow { font-size: 17px; font-weight: 700; line-height: 1; } .ptr .spinner { display: none; width: 18px; height: 18px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; } .ptr.committed { color: #ff4d5e; border-color: #ff4d5e; background: rgba(255, 77, 94, 0.20); box-shadow: 0 4px 16px rgba(255, 77, 94, 0.35); } .ptr.spinning .arrow { display: none; } .ptr.spinning .spinner { display: block; animation: ptr-spin 0.7s linear infinite; } @keyframes ptr-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .topbar { position: sticky; top: 0; z-index: 20; background: rgba(11,13,18,0.94); backdrop-filter: saturate(160%) blur(10px); -webkit-backdrop-filter: saturate(160%) blur(10px); border-bottom: 1px solid #1f2330; } header.top { padding: 10px 16px; padding-top: max(10px, env(safe-area-inset-top)); display: flex; justify-content: space-between; align-items: center; gap: 10px; } header.top .titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; } header.top h1 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; color: #f0f0f0; animation: refresh-flash 0.8s ease-out; } header.top .meta { font-size: 11px; color: #8b8f9a; font-variant-numeric: tabular-nums; animation: refresh-flash-meta 0.8s ease-out; } /* 페이지 로드(첫 진입·새로고침) 시 1회 발동. 탭 클릭(hashchange)은 reload 아니므로 발동 안 함. */ @keyframes refresh-flash { 0%, 20% { color: #ff4d5e; } 100% { color: #f0f0f0; } } @keyframes refresh-flash-meta { 0%, 20% { color: #ff4d5e; } 100% { color: #8b8f9a; } } .refresh { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 8px; background: #1c2230; border: 1px solid #2a3142; color: #e6e6e6; font-size: 17px; text-decoration: none; user-select: none; transition: background 0.15s, transform 0.1s; } .refresh:hover { background: #232a3b; } .refresh:active { transform: scale(0.95); background: #2a3245; } .refresh.spinning { background: rgba(255,77,94,0.15); border-color: #ff4d5e; color: #ff4d5e; } .top-actions { display: flex; align-items: center; gap: 8px; flex: none; } .auto-toggle { flex: none; display: inline-flex; align-items: center; gap: 6px; padding: 0 10px; height: 36px; border-radius: 8px; background: #1c2230; border: 1px solid #2a3142; color: #8b8f9a; font: 600 12px/1 inherit; font-variant-numeric: tabular-nums; cursor: pointer; user-select: none; transition: background 0.15s, color 0.15s, border-color 0.15s; } .auto-toggle:hover { background: #232a3b; } .auto-toggle:active { transform: scale(0.95); } .auto-toggle .auto-dot { width: 7px; height: 7px; border-radius: 50%; background: #5a5f6c; transition: background 0.15s, box-shadow 0.15s; } .auto-toggle[aria-pressed="true"] { background: rgba(255,77,94,0.15); border-color: #ff4d5e; color: #ff4d5e; } .auto-toggle[aria-pressed="true"] .auto-dot { background: #ff4d5e; box-shadow: 0 0 6px rgba(255,77,94,0.7); animation: auto-pulse 1.5s ease-in-out infinite; } .auto-toggle.closed, .auto-toggle.closed:hover { background: #15171f; border-color: #1f2330; color: #5a5f6c; cursor: not-allowed; } .auto-toggle.closed:active { transform: none; } .auto-toggle.closed .auto-dot { background: #3a3f4a; box-shadow: none; animation: none; } @keyframes auto-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } } .section-label { padding: 22px 18px 8px; margin-top: 10px; font-size: 12px; font-weight: 600; letter-spacing: 0.05em; color: #8b8f9a; border-top: 1px solid #2a3142; } /* row 바로 다음에 오는 section-label은 분리감 더 강조 — '당일 매매' 끝 → '보유 종목' 시작 경계. */ details.row + .section-label { margin-top: 14px; padding-top: 24px; border-top-color: #3a4154; } .section-label .count { color: #5a5f6c; font-weight: 400; margin-left: 4px; } .row { border-bottom: 1px solid #14171f; } .row > summary { display: grid; grid-template-columns: 1fr auto; gap: 4px 16px; padding: 8px 18px; cursor: pointer; list-style: none; font-variant-numeric: tabular-nums; user-select: none; transition: background 0.1s; } /* candle-mini는 grid flow에서 빠지고 카드 row 우측 끝에 absolute 고정 — grid 충돌·implicit row 차단. */ .row > summary:has(.candle-mini) { position: relative; padding-right: 56px; } .candle-mini { position: absolute; top: 50%; right: 14px; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; pointer-events: none; } .row > summary::-webkit-details-marker { display: none; } .row > summary::marker { display: none; } .row:hover > summary { background: #11141c; } .row[open] > summary { background: #14171f; } .left { min-width: 0; display: flex; flex-direction: column; gap: 6px; justify-content: center; } .right { text-align: right; display: flex; flex-direction: column; gap: 2px; align-items: flex-end; justify-content: center; } .line1 { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; row-gap: 4px; } .stock { font-size: 14px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .code { font-size: 11px; color: #5a5f6c; font-weight: 400; flex: none; } .line2 { display: flex; align-items: center; gap: 8px; font-size: 12px; min-width: 0; } .price { font-size: 18px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em; display: flex; align-items: center; gap: 6px; } .right .ref { font-size: 12px; color: #8b8f9a; font-weight: 400; font-variant-numeric: tabular-nums; line-height: 1.2; } .diff { font-size: 13px; font-weight: 500; color: #8b8f9a; } .diff .pct { font-size: 13px; margin-left: 4px; opacity: 0.95; } .row.up .price, .row.up .diff { color: #ff4d5e; } .row.down .price, .row.down .diff { color: #4a8cf0; } .day-pct { font-size: 13px; font-weight: 500; } .day-pct.up { color: #ff4d5e; } .day-pct.down { color: #4a8cf0; } .day-pct.neutral { color: #8b8f9a; } .open-pct { display: inline-block; margin-left: 4px; font-size: 11px; font-weight: 500; font-variant-numeric: tabular-nums; } .open-pct.up { color: #ff4d5e; } .open-pct.down { color: #4a8cf0; } .open-pct.neutral { color: #8b8f9a; } /* OHLC mini-table — 라벨/가격/시가대비%/전일대비% 4-col grid. 첫 줄에 시가/전일 컬럼 헤더가 한 번만 표시되고, 4행(현재가/시가/고가/저가)이 컬럼 단위로 정렬된다. `시가` 행의 시가 컬럼은 자기 자신이라 `-` placeholder. */ .ohlc-mini { display: grid; grid-template-columns: max-content max-content 1fr 1fr; column-gap: 14px; row-gap: 4px; margin: 8px 0 4px; width: 100%; font-variant-numeric: tabular-nums; align-items: baseline; } .ohlc-mini .ohlc-th { font-size: 10px; color: #8b8f9a; font-weight: 500; text-align: right; padding-bottom: 2px; border-bottom: 1px solid #1f2330; } .ohlc-mini .ohlc-th:first-child, .ohlc-mini .ohlc-th:nth-child(2) { text-align: left; border-bottom: none; } .ohlc-mini .ohlc-rl { color: #8b8f9a; font-size: 12px; } .ohlc-mini .ohlc-price { color: #d6d6d6; font-size: 13px; font-weight: 500; text-align: left; } .ohlc-mini .ohlc-pct { font-size: 12px; font-weight: 500; text-align: right; min-width: 56px; } .ohlc-mini .ohlc-pct.up { color: #ff4d5e; } .ohlc-mini .ohlc-pct.down { color: #4a8cf0; } .ohlc-mini .ohlc-pct.neutral { color: #8b8f9a; } /* 보유종목 자세히 보기 — KRX/NXT 두 거래소 분리 표시. 3-col grid (라벨/KRX셀/NXT셀). */ .ohlc-mini.ohlc-dual { grid-template-columns: max-content 1fr 1fr; column-gap: 16px; } .ohlc-mini.ohlc-dual .ohlc-th { text-align: center; border-bottom: 1px solid #1f2330; } .ohlc-mini.ohlc-dual .ohlc-th:first-child { text-align: left; border-bottom: none; } .ohlc-mini.ohlc-dual .ohlc-cell { display: flex; flex-direction: row; align-items: baseline; justify-content: flex-end; gap: 8px; } .ohlc-mini.ohlc-dual .ohlc-cell .ohlc-price { font-size: 13px; text-align: right; } .ohlc-mini.ohlc-dual .ohlc-cell .ohlc-pct { font-size: 12px; min-width: auto; } .ohlc-mini.ohlc-dual .ohlc-cell-empty { color: #5a5e69; text-align: right; font-size: 12px; } /* 비활성 시장 컬럼 회색 처리 — 헤더·셀·가격·등락률 모두 흐리게. */ .ohlc-mini.ohlc-dual .ohlc-th.ohlc-dim { color: #4a4e58; } .ohlc-mini.ohlc-dual .ohlc-cell.ohlc-dim .ohlc-price { color: #5a5e69; } .ohlc-mini.ohlc-dual .ohlc-cell.ohlc-dim .ohlc-pct { color: #5a5e69 !important; } .ohlc-mini.ohlc-dual .ohlc-cell-empty.ohlc-dim { color: #3a3e48; } /* 미리보기 현재가 왼쪽 출처 마크 — NXT(파랑톤) / KRX(회색톤). 어두운 채도로 보조 정보 강조 X. */ .px-mark { font-size: 8px; font-weight: 600; margin-right: 4px; padding: 1px 3px; border-radius: 2px; background: #14171f; vertical-align: 2px; letter-spacing: 0.3px; } .px-mark.px-nxt { color: #2d5a8a; } .px-mark.px-krx { color: #4a4e58; } .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex: none; box-shadow: 0 0 6px currentColor; } .dot-1 { background: #f5d423; color: rgba(245,212,35,0.5); } .dot-2 { background: #f59023; color: rgba(245,144,35,0.55); } .dot-3 { background: #ff4d5e; color: rgba(255,77,94,0.6); } /* 매입 총액 등급 뱃지 — 군대 계급 모티프(가로 막대 1~5개). 노랑→진한빨강 그라데이션. */ .rank-badge { display: inline-flex; flex-direction: column; justify-content: center; gap: 1.5px; flex: none; padding: 0 1px; } .rank-badge .stripe { width: 9px; height: 1.5px; border-radius: 1px; } .rank-1 .stripe { background: #ffe082; box-shadow: 0 0 4px rgba(255,224,130,0.45); } .rank-2 .stripe { background: #ffc107; box-shadow: 0 0 4px rgba(255,193,7,0.5); } .rank-3 .stripe { background: #ff9800; box-shadow: 0 0 5px rgba(255,152,0,0.55); } .rank-4 .stripe { background: #f57c00; box-shadow: 0 0 5px rgba(245,124,0,0.6); } .rank-5 .stripe { background: #d32f2f; box-shadow: 0 0 6px rgba(211,47,47,0.65); } .muted { color: #5a5f6c; font-weight: 400; } .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 10px; font-weight: 500; line-height: 1.6; font-variant-numeric: tabular-nums; white-space: nowrap; flex: none; letter-spacing: 0.02em; } .tag-chip { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; line-height: 1.5; color: #d6b34a; background: rgba(214,179,74,0.10); border: 1px solid rgba(214,179,74,0.30); white-space: nowrap; flex: none; letter-spacing: 0.01em; font-family: inherit; pointer-events: none; user-select: none; } .tag-chip.tag-leader { color: #ff6b78; background: rgba(255,77,94,0.12); border-color: rgba(255,77,94,0.42); font-weight: 600; } .btn-add-tag { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; line-height: 1.5; color: #5a5f6c; background: transparent; border: 1px dashed rgba(120,124,135,0.30); white-space: nowrap; flex: none; cursor: pointer; font-family: inherit; } .btn-add-tag:hover { color: #d6b34a; border-color: rgba(214,179,74,0.45); } .tag-quick-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; } .btn-tag-quick { padding: 4px 14px; border-radius: 6px; font-size: 12px; font-weight: 600; color: #b8bcc6; background: #1a1e2a; border: 1px solid #2a2f3c; cursor: pointer; font-family: inherit; letter-spacing: 0.02em; } .btn-tag-quick:hover { background: #20242f; color: #f0f0f0; } .btn-tag-quick.active { color: #ff6b78; background: rgba(255,77,94,0.14); border-color: rgba(255,77,94,0.50); } .btn-tag-quick.active:hover { background: rgba(255,77,94,0.22); } .badge.watching { background: #1a1e2a; color: #7a8090; } .badge.held { background: #2a1820; color: #ff8a96; border: 1px solid rgba(255,77,94,0.3); } .badge.pending-buy { background: #2a1822; color: #ff9fb3; border: 1px solid rgba(255,140,170,0.32); font-weight: 600; padding: 2px 6px; letter-spacing: 0; } .badge.pending-sell { background: #182030; color: #7fb8ff; border: 1px solid rgba(122,180,255,0.32); font-weight: 600; padding: 2px 6px; letter-spacing: 0; } dl.pending-detail { margin-top: 8px; padding-top: 8px; border-top: 1px dashed rgba(255,255,255,0.08); grid-template-columns: 92px minmax(0, 1fr); } .row.mode-held .detail dl.pending-detail { grid-template-columns: 92px minmax(0, 1fr); } dl.pending-detail dt { font-weight: 600; letter-spacing: 0; white-space: nowrap; } dl.pending-detail dt.pending-buy-label { color: #ff9fb3; } dl.pending-detail dt.pending-sell-label { color: #7fb8ff; } dl.pending-detail dd { line-height: 1.45; } dl.pending-detail .pending-line-main { font-variant-numeric: tabular-nums; } dl.pending-detail .pending-line-meta { margin-top: 2px; } .caret { color: #4a4f5a; font-size: 10px; transition: transform 0.2s; } .row[open] .caret { transform: rotate(180deg); color: #8b8f9a; } .detail { padding: 6px 16px 16px; background: #0d1018; border-top: 1px solid #14171f; font-size: 13px; } .detail dl { display: grid; grid-template-columns: 80px 1fr; gap: 6px 14px; margin: 6px 0 14px; } .row.mode-held .detail dl { grid-template-columns: 80px minmax(0, 1fr) 80px minmax(0, 1fr); column-gap: 14px; row-gap: 6px; } .row.mode-held .detail dt.empty, .row.mode-held .detail dd.empty { visibility: hidden; } .row.mode-held .detail dl.pl-summary { grid-template-columns: 92px minmax(0, 1fr); margin-top: 10px; padding-top: 10px; border-top: 1px solid #1f2330; } .row.mode-held .detail dl.pl-summary dt { white-space: nowrap; } .row.mode-held .detail dl.pl-summary .pl-divider { grid-column: 1 / -1; border-top: 1px solid #1f2330; margin: 4px 0; height: 0; } @media (max-width: 640px) { .row.mode-held .detail dl.pl-summary { grid-template-columns: 92px minmax(0, 1fr); } } @media (max-width: 640px) { .row.mode-held .detail dl { grid-template-columns: 64px minmax(0, 1fr) 64px minmax(0, 1fr); column-gap: 8px; } } .detail dt { color: #8b8f9a; font-size: 12px; } .detail dd { margin: 0; color: #d6d6d6; } /* 워치리스트·관심종목 카드 본문 — 라벨/값 2-col grid (매수가/목표가/손절가/저장일·매입평단). OHLC는 별도 `.ohlc-mini` 블록으로 분리. */ .detail .grid2 { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 12px; row-gap: 6px; margin: 6px 0 14px; } .detail .grid2 > .lbl { color: #8b8f9a; font-size: 12px; white-space: nowrap; align-self: center; } .detail .grid2 > .val { color: #d6d6d6; font-size: 13px; word-break: break-all; min-width: 0; align-self: center; } .detail .grid2 > .val.num { font-variant-numeric: tabular-nums; } .detail .grid2 > .val.muted { color: #8b8f9a; } @media (max-width: 480px) { .detail .grid2 { column-gap: 8px; row-gap: 5px; } .detail .grid2 > .lbl { font-size: 11px; } .detail .grid2 > .val { font-size: 12px; } } .detail .block { margin: 12px 0 0; } .detail h3 { margin: 0 0 6px; font-size: 11px; color: #8b8f9a; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; } .detail ul { margin: 0; padding-left: 18px; line-height: 1.6; color: #c9ccd3; } .detail li { margin-bottom: 3px; } .detail .video-link { color: #7aa2ff; text-decoration: none; } .detail .video-link:hover { text-decoration: underline; } .detail .block.chart { margin: 8px 0 14px; } .detail .block.chart img { display: block; width: 100%; max-width: 700px; height: auto; border-radius: 6px; background: #fff; } .detail .block.chart-svg { margin: 8px 0 14px; min-height: 200px; border-radius: 6px; overflow: hidden; } .detail .block.chart-svg svg { width: 100%; height: auto; display: block; } .detail .block.chart-svg .chart-loading { padding: 60px 0; text-align: center; color: #7a8493; font-size: 12px; } .detail .block.chart-svg .chart-error { padding: 30px 12px; text-align: center; color: #ef4444; font-size: 12px; } .detail .block.chart-svg .chart-tabs { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; } .detail .block.chart-svg .chart-tabs button { padding: 5px 12px; background: #1f2937; color: #7a8493; border: 1px solid #2a2f3a; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; font-family: inherit; } .detail .block.chart-svg .chart-tabs button:hover { color: #cfd5df; border-color: #3f4654; } .detail .block.chart-svg .chart-tabs button.active { background: rgba(251,191,36,0.12); color: #fbbf24; border-color: #fbbf24; } .detail .block.chart-svg .chart-panel { display: none; } .detail .block.chart-svg .chart-panel.active { display: block; } .detail .block.chart-svg .chart-subtabs { display: flex; gap: 4px; margin-bottom: 6px; flex-wrap: wrap; } .detail .block.chart-svg .chart-subtabs button { padding: 3px 9px; background: transparent; color: #7a8493; border: 1px solid #2a2f3a; border-radius: 5px; cursor: pointer; font-size: 11px; font-weight: 600; font-family: inherit; } .detail .block.chart-svg .chart-subtabs button:hover { color: #cfd5df; border-color: #3f4654; } .detail .block.chart-svg .chart-subtabs button.active { background: rgba(52,211,153,0.10); color: #34d399; border-color: rgba(52,211,153,0.45); } .detail .block.chart-svg .chart-subpanel { min-height: 200px; } .actions { margin-top: 14px; padding-top: 12px; border-top: 1px solid #1a1e29; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .actions form { display: inline; margin: 0; } .actions button { font: inherit; cursor: pointer; padding: 6px 14px; border-radius: 6px; border: 1px solid #2a3142; background: transparent; color: #b0b4be; font-size: 12px; font-weight: 500; transition: background 0.12s, border-color 0.12s; } .actions button:hover { background: #1c2030; } .actions button:active { transform: scale(0.97); } .actions .btn-delete { color: #ff8a96; border-color: rgba(255,77,94,0.32); } .actions .btn-delete:hover { background: rgba(255,77,94,0.08); } .actions .btn-alerts-reset { color: #c9ccd3; border-color: rgba(201,204,211,0.28); } .actions .btn-alerts-reset:hover { background: rgba(201,204,211,0.06); border-color: #6aa9ff; color: #6aa9ff; } .alert-mark { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; color: #ffd66e; background: rgba(255,214,110,0.10); border: 1px solid rgba(255,214,110,0.32); vertical-align: middle; } .badge.alerted { color: #ffd66e; background: rgba(255,214,110,0.10); border: 1px solid rgba(255,214,110,0.32); padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; } .alert-notice { margin: 0 0 10px; padding: 8px 10px; border-radius: 6px; font-size: 12px; color: #ffd66e; background: rgba(255,214,110,0.08); border-left: 3px solid #ffd66e; } .buy-raw { color: #c9ccd3; margin-bottom: 4px; } .buy-level { color: #e6e6e6; line-height: 1.5; } .buy-level + .buy-level { margin-top: 2px; } .actions .btn-restore { color: #5cd99a; border-color: rgba(46,194,126,0.32); } .actions .btn-restore:hover { background: rgba(46,194,126,0.08); } .actions .btn-purge { color: #fff; background: #c43c3c; border-color: #c43c3c; } .actions .btn-purge:hover { background: #d34a4a; border-color: #d34a4a; } .actions .btn-edit { color: #c9ccd3; border-color: rgba(201,204,211,0.28); } .actions .btn-edit:hover { background: rgba(201,204,211,0.06); border-color: #ff4d5e; color: #ff4d5e; } .actions .small { font-size: 11px; margin-right: auto; } .section-label.trash-label { color: #6c7180; } .row.trash > summary { opacity: 0.5; } .row.trash[open] > summary { opacity: 0.85; } .empty { padding: 60px 18px; text-align: center; color: #6c7180; } .add-trigger-wrap { padding: 20px 14px 28px; display: flex; justify-content: center; } .btn-add-trigger { background: #14171f; color: #e6e6e6; border: 1px dashed #2f3543; border-radius: 10px; padding: 12px 22px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; letter-spacing: 0.02em; } .btn-add-trigger:hover { border-color: #ff4d5e; color: #ff4d5e; } .btn-add-trigger:active { transform: translateY(1px); } .modal { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: max(60px, calc(env(safe-area-inset-top) + 68px)) 20px 20px; } .modal.modal-top { z-index: 1010; } .modal.hidden { display: none; } .modal-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(2px); } .modal-box { position: relative; background: #0d1018; border: 1px solid #1f2330; border-radius: 12px; padding: 18px 18px 16px; width: 100%; max-width: 480px; max-height: calc(100dvh - env(safe-area-inset-top) - 96px); overflow-y: auto; box-shadow: 0 12px 40px rgba(0,0,0,0.45); } .modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .modal-title { font-size: 14px; font-weight: 600; color: #f0f0f0; letter-spacing: 0.02em; } .modal-close { background: transparent; border: 0; color: #8b8f9a; font-size: 22px; line-height: 1; cursor: pointer; padding: 4px 8px; font-family: inherit; } .modal-close:hover { color: #f0f0f0; } .form-feedback { margin-top: 12px; } .feedback-error { background: #2a1418; border: 1px solid #5a1f28; color: #ff8a95; border-radius: 8px; padding: 10px 12px; font-size: 13px; line-height: 1.5; } .feedback-title { color: #c9ccd3; font-size: 12px; margin-bottom: 8px; line-height: 1.5; } .candidate-list { display: flex; flex-direction: column; gap: 6px; } .candidate-btn { display: flex; align-items: center; justify-content: space-between; gap: 10px; background: #14171f; border: 1px solid #1f2330; color: #e6e6e6; border-radius: 8px; padding: 10px 12px; font-size: 14px; font-family: inherit; cursor: pointer; text-align: left; } .candidate-btn:hover { border-color: #ff4d5e; } .candidate-btn:active { transform: translateY(1px); } .candidate-btn .cand-code { color: #6c7180; font-size: 12px; font-variant-numeric: tabular-nums; } .modal-actions { display: flex; gap: 8px; margin-top: 14px; } .modal-actions .info-analyze-link { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 4px; background: rgba(251,191,36,0.08); color: #fbbf24; border: 1px solid rgba(251,191,36,0.35); border-radius: 8px; padding: 10px 14px; font-size: 13px; font-weight: 600; text-decoration: none; font-family: inherit; } .modal-actions .info-analyze-link:hover { background: rgba(251,191,36,0.16); border-color: #fbbf24; } .modal-actions .info-analyze-link:active { transform: translateY(1px); } .modal-actions .btn-cancel { flex: 1; background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 8px; padding: 10px 14px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; } .modal-actions .btn-add { flex: 2; background: #ff4d5e; color: #fff; border: 0; border-radius: 8px; padding: 10px 16px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; } .modal-actions .btn-add:active, .modal-actions .btn-cancel:active { transform: translateY(1px); } .modal-head .btn-delete { background: transparent; color: #ff8a96; border: 1px solid rgba(255,77,94,0.32); border-radius: 6px; padding: 4px 10px; font-size: 12px; font-weight: 500; cursor: pointer; font-family: inherit; } .modal-head .btn-delete:hover { background: rgba(255,77,94,0.08); } .modal-head .btn-delete:active { transform: translateY(1px); } .add-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 12px; } .add-form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: #8b8f9a; } .add-form-grid input { background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 9px 10px; color: #e6e6e6; font-size: 16px; font-family: inherit; } .add-form-grid input:focus { outline: none; border-color: #ff4d5e; } @media (max-width: 480px) { .add-form-grid { grid-template-columns: 1fr; } .modal { padding: max(56px, calc(env(safe-area-inset-top) + 60px)) 12px 12px; } .modal-box { padding: 16px 14px 14px; } } .loading { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 60px 18px; color: #8b8f9a; font-size: 13px; } .loading-spin { width: 16px; height: 16px; border: 2px solid #2a2e3a; border-top-color: #ff4d5e; border-radius: 50%; animation: behive-spin 0.8s linear infinite; } @keyframes behive-spin { to { transform: rotate(360deg); } } .actions .btn-trades { color: #c9ccd3; border-color: rgba(201,204,211,0.28); } .actions .btn-trades:hover { background: rgba(201,204,211,0.06); border-color: #6aa9ff; color: #6aa9ff; } .actions .btn-order { color: #ff8a95; border: 1px solid rgba(255,138,149,0.35); background: rgba(255,138,149,0.06); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; } .actions .btn-order:hover { background: rgba(255,138,149,0.14); border-color: #ff8a95; } .actions .btn-order:active { transform: translateY(1px); } .actions .btn-analyze { color: #fbbf24; border: 1px solid rgba(251,191,36,0.35); background: rgba(251,191,36,0.06); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; gap: 3px; } .actions .btn-analyze:hover { background: rgba(251,191,36,0.12); border-color: #fbbf24; } .actions .btn-info { color: #7dd3a8; border: 1px solid rgba(125,211,168,0.35); background: rgba(125,211,168,0.06); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; } .actions .btn-info:hover { background: rgba(125,211,168,0.14); border-color: #7dd3a8; } .actions .btn-info:active { transform: translateY(1px); } .info-body { display: flex; flex-direction: column; gap: 4px; } .info-grid { display: grid; grid-template-columns: max-content 1fr; gap: 7px 16px; align-items: baseline; } .info-grid dt { color: #8b8f9a; font-size: 12px; } .info-grid dd { margin: 0; font-size: 13px; font-variant-numeric: tabular-nums; text-align: right; color: #e8eaed; } .info-grid dd .up { color: #ff4d5e; } .info-grid dd .down { color: #6aa9ff; } .info-sec-title { color: #6c7180; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; margin: 12px 0 4px; } .info-sec-title:first-child { margin-top: 0; } /* 기업정보 모달 탭 (요약/기업정보·가치/성장성·컨센서스/투자리포트) — 자산탭 sub-tab과 분리된 전용 클래스 */ .info-tabs { display: flex; gap: 2px; margin: 0 0 12px; border-bottom: 1px solid #1f2330; flex-wrap: wrap; } .info-tabbtn { appearance: none; background: transparent; color: #8b8f9a; border: 0; border-bottom: 2px solid transparent; padding: 8px 11px; font-size: 13px; cursor: pointer; font-family: inherit; font-weight: 600; white-space: nowrap; } .info-tabbtn:hover { color: #d6d8dd; } .info-tabbtn.active { color: #f0f0f0; border-bottom-color: #ff4d5e; } .info-tabpanel[hidden] { display: none; } .info-q { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0; margin-left: 4px; border-radius: 50%; border: 1px solid rgba(125,211,168,0.4); background: rgba(125,211,168,0.08); color: #7dd3a8; font-size: 10px; font-weight: 700; line-height: 1; cursor: pointer; font-family: inherit; vertical-align: middle; } .info-q:hover { background: rgba(125,211,168,0.2); border-color: #7dd3a8; } .info-q:active { transform: translateY(1px); } .info-desc-box { max-width: 420px; } .info-desc-body { font-size: 13px; line-height: 1.7; color: #d4d7dd; padding: 4px 2px 8px; } /* 기업비교 바 */ .info-compare-bar { display: flex; flex-direction: column; gap: 6px; margin: 2px 0 10px; } .info-search-row { display: flex; gap: 6px; } .info-search-input { flex: 1; min-width: 0; background: #14171f; color: #e8eaed; border: 1px solid #1f2330; border-radius: 8px; padding: 8px 10px; font-size: 13px; font-family: inherit; } .info-search-input:focus { outline: none; border-color: #7dd3a8; } .info-search-btn { flex: 0 0 auto; background: rgba(125,211,168,0.1); color: #7dd3a8; border: 1px solid rgba(125,211,168,0.35); border-radius: 8px; padding: 8px 14px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; } .info-search-btn:hover { background: rgba(125,211,168,0.18); } .info-symbols-select { width: 100%; background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 8px; padding: 8px 10px; font-size: 13px; font-family: inherit; color-scheme: dark; } .info-search-results { display: flex; flex-wrap: wrap; gap: 6px; margin: -4px 0 10px; } .info-search-results.hidden { display: none; } .info-search-hit { background: #14171f; color: #e8eaed; border: 1px solid #2a2e3a; border-radius: 8px; padding: 6px 10px; font-size: 12px; cursor: pointer; font-family: inherit; } .info-search-hit:hover { border-color: #7dd3a8; color: #7dd3a8; } /* 비교표 */ .info-cmp-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; } .info-cmp-table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; } .info-cmp-table th, .info-cmp-table td { padding: 8px 10px; text-align: right; font-size: 13px; white-space: nowrap; border-bottom: 1px solid #1c2030; } .info-cmp-table thead th { color: #e8eaed; font-weight: 700; border-bottom: 1px solid #2a2e3a; position: relative; } .info-cmp-table .info-cmp-label { text-align: left; color: #8b8f9a; font-weight: 500; position: sticky; left: 0; background: #0d1018; z-index: 1; } .info-cmp-table thead .info-cmp-label { background: #0d1018; } .info-cmp-name { color: #e8eaed; } .info-cmp-remove { margin-left: 6px; width: 16px; height: 16px; padding: 0; border-radius: 50%; border: 1px solid rgba(255,138,149,0.4); background: rgba(255,138,149,0.08); color: #ff8a95; font-size: 11px; line-height: 1; cursor: pointer; font-family: inherit; vertical-align: middle; } .info-cmp-remove:hover { background: rgba(255,138,149,0.2); } .badge.badge-trade { color: #6c7180; background: transparent; border: 1px solid rgba(120,124,135,0.20); cursor: pointer; font-family: inherit; } .badge.badge-trade:hover { color: #c9ccd3; border-color: rgba(201,204,211,0.38); background: rgba(201,204,211,0.04); } .modal-box-wide { max-width: 720px; } .trade-summary { margin: -6px 0 10px; line-height: 1.5; font-size: 11px; } .trade-body { overflow-x: auto; } .trade-table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; } .trade-table th, .trade-table td { padding: 7px 8px; border-bottom: 1px solid #1f2330; text-align: right; white-space: nowrap; } .trade-table th { color: #8b8f9a; font-weight: 600; background: #0d1018; position: sticky; top: 0; } .trade-table td.l, .trade-table th.l { text-align: left; } .trade-table tr.seed td { background: rgba(255,255,255,0.025); color: #8b8f9a; } .trade-table .pl-pos { color: #ff4d5e; } .trade-table .pl-neg { color: #2c7be5; } .trade-table .muted { color: #6c7180; } .trade-table .side-buy { color: #ff4d5e; font-weight: 600; } .trade-table .side-sell { color: #2c7be5; font-weight: 600; } .trade-table .trade-stock-col { max-width: 110px; overflow: hidden; text-overflow: ellipsis; } .trade-table td.l, .trade-table th.l { font-size: 10.5px; } .trade-pager-wrap { display: block; } .trade-pager { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 12px; user-select: none; } .trade-pager .pg-btn { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 8px; padding: 6px 14px; font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit; line-height: 1; } .trade-pager .pg-btn:hover:not(:disabled) { border-color: #6aa9ff; color: #6aa9ff; } .trade-pager .pg-btn:active:not(:disabled) { transform: translateY(1px); } .trade-pager .pg-btn:disabled { opacity: 0.35; cursor: not-allowed; } .trade-pager .pg-indicator { color: #c9ccd3; font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; min-width: 56px; text-align: center; } .trade-stock-col { cursor: pointer; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; } .trade-list { list-style: none; padding: 0; margin: 0; } .trade-item { padding: 8px 10px; border-bottom: 1px solid #1f2330; display: flex; flex-direction: column; gap: 3px; font-variant-numeric: tabular-nums; } .trade-item.seed { background: rgba(255,255,255,0.025); color: #8b8f9a; } .trade-item.placeholder { visibility: hidden; } body.modal-open { position: fixed; left: 0; right: 0; width: 100%; overflow: hidden; } .ti-row1 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: 12px; color: #8b8f9a; } .ti-meta { display: flex; align-items: baseline; gap: 6px; flex: 1 1 auto; min-width: 0; flex-wrap: wrap; } .ti-row2 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: 13px; color: #e6e6e6; } .ti-data { display: flex; align-items: baseline; gap: 6px; flex: 1 1 auto; min-width: 0; flex-wrap: wrap; } .ti-side { font-weight: 600; } .ti-side.side-buy { color: #ff4d5e; } .ti-side.side-sell { color: #2c7be5; } .ti-pl { font-weight: 600; font-size: 12px; flex: 0 0 auto; white-space: nowrap; } .ti-pl.pl-pos { color: #ff4d5e; } .ti-pl.pl-neg { color: #2c7be5; } .ti-pl.muted { color: #6c7180; } .ti-stock { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ti-row1 .ti-stock { color: #c9ccd3; font-weight: 500; } .ti-sep { color: #4a4f5a; } .ti-label { color: #6c7180; font-size: 11px; font-weight: 500; margin-right: 2px; } .ti-stock-line { color: #c9ccd3; font-weight: 600; font-size: 12.5px; line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 2px; } .stock-name-tip { position: fixed; z-index: 2000; display: none; max-width: 260px; padding: 7px 11px; background: #1c2230; color: #f0f0f0; font-size: 13px; font-weight: 600; line-height: 1.35; border: 1px solid #2d3340; border-radius: 8px; box-shadow: 0 6px 20px rgba(0,0,0,0.55); pointer-events: none; word-break: keep-all; white-space: normal; } .stock-name-tip.show { display: block; } .stock-name-tip::after { content: ''; position: absolute; left: 50%; bottom: -6px; width: 0; height: 0; transform: translateX(-50%); border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 6px solid #1c2230; } .stock-name-tip.below::after { top: -6px; bottom: auto; border-top: 0; border-bottom: 6px solid #1c2230; } .owner-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin-bottom: 2px; } .btn-trades-all { background: #14171f; color: #c9ccd3; border: 1px solid rgba(201,204,211,0.28); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; flex: 0 0 auto; } .btn-trades-all:hover { background: rgba(201,204,211,0.06); border-color: #6aa9ff; color: #6aa9ff; } .btn-trades-all:active { transform: translateY(1px); } .trade-tabs { display: flex; gap: 2px; margin: 0 0 10px; border-bottom: 1px solid #1f2330; } .trade-tab { background: transparent; border: 0; padding: 8px 14px; color: #8b8f9a; font-size: 12px; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; } .trade-tab:hover { color: #c9ccd3; } .trade-tab[aria-selected="true"] { color: #c9ccd3; border-bottom-color: #ff4d5e; } .trade-tab-panel[hidden] { display: none; } .trade-group-sub { font-variant-numeric: tabular-nums; margin: 0 0 8px; font-size: 11px; } @media (max-width: 600px) { .row > summary { padding: 8px 14px; } .stock { font-size: 13px; } .code { font-size: 10px; } .price { font-size: 17px; } .diff { font-size: 12px; } .diff .pct { font-size: 12px; } .line2 { gap: 6px; font-size: 11px; } .badge { font-size: 10px; padding: 2px 7px; } .detail { padding: 6px 14px 14px; } .detail dl { grid-template-columns: 72px 1fr; } .section-label { padding: 18px 14px 6px; } details.row + .section-label { padding-top: 20px; } } /* ── 탭 (URL fragment + html[data-tab] attribute, FOUC 방지) ── */ /* head의 inline script가 body 렌더 전에 location.hash → documentElement.dataset.tab 동기화. 첫 paint 시점에 이미 올바른 탭이 표시되어 새로고침 깜빡임 없음. 섹션은 data-tab 속성으로 매칭 (id 충돌 회피). 탭 anchor 타겟은 없으므로 클릭해도 스크롤 안 일어남. */ nav.tabs { display: flex; gap: 0; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; } nav.tabs::-webkit-scrollbar { display: none; } nav.tabs a.tab-label { flex: none; padding: 12px 16px; cursor: pointer; user-select: none; font-size: 13px; font-weight: 500; color: #8b8f9a; border-bottom: 2px solid transparent; white-space: nowrap; transition: color 0.12s, border-color 0.12s; font-variant-numeric: tabular-nums; text-decoration: none; } nav.tabs a.tab-label:hover { color: #c9ccd3; } .tab-content { display: none; } /* 짧은 탭(자산정보 등)도 viewport를 채워 모바일 Safari 주소창 토글이 일관되게 동작하도록. ticker(fixed bottom) 위치가 다른 탭과 시각적으로 동일해진다. */ section.tab-content { min-height: calc(100dvh - 110px); } @supports not (height: 100dvh) { section.tab-content { min-height: calc(100vh - 110px); } } /* ── 계좌현황 패널 ── */ .owner-card { margin: 14px 14px 8px; padding: 14px 16px; background: #11141c; border: 1px solid #1f2330; border-radius: 10px; } .owner-title { font-size: 15px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em; } .owner-sub { font-size: 11px; color: #5a5f6c; margin-top: 2px; margin-bottom: 10px; } .owner-card table.kpi { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; } .owner-card table.kpi th { text-align: left; font-weight: 400; color: #8b8f9a; padding: 4px 8px 4px 0; font-size: 12px; width: 110px; vertical-align: top; white-space: nowrap; } .owner-card table.kpi td { padding: 4px 0; font-size: 13px; color: #e6e6e6; font-weight: 500; text-align: right; } .owner-card table.kpi td .net { color: #f0f0f0; font-weight: 600; } .owner-card table.kpi td .up { color: #ff4d5e; font-weight: 600; } .owner-card table.kpi td .down { color: #4a8cf0; font-weight: 600; } .owner-card table.kpi td .neutral { color: #c9ccd3; } .cash-list { list-style: none; margin: 4px 14px 12px; padding: 8px 12px; background: #0d1018; border: 1px solid #14171f; border-radius: 8px; font-size: 12px; color: #c9ccd3; font-variant-numeric: tabular-nums; } .cash-list li { padding: 2px 0; } .detail dl .num { font-variant-numeric: tabular-nums; } .detail .up { color: #ff4d5e; font-weight: 600; } .detail .down { color: #4a8cf0; font-weight: 600; } .detail .neutral { color: #c9ccd3; } .star { color: #d97706; font-size: 12px; flex: none; } .star.buy { color: #ff4d5e; } .star.sell { color: #4a8cf0; } .star.buy + .star.sell { margin-left: 2px; } .badge.phantom { background: #2a2618; color: #f5b441; border: 1px solid rgba(245,180,65,0.3); } .row.mode-held .right .ref { color: #8b8f9a; } .row.mode-phantom .price { font-size: 15px; } .net-chart { margin: 12px 14px 0; padding: 10px 12px 8px; background: #11141c; border: 1px solid #1f2330; border-radius: 10px; position: relative; } .net-chart-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 4px; } .net-chart-title { font-size: 12px; color: #8b8f9a; letter-spacing: -0.01em; } .net-chart-delta { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; } .net-chart-delta.up { color: #ff4d5e; } .net-chart-delta.down { color: #4a8cf0; } .net-chart-options { display: flex; flex-wrap: nowrap; gap: 6px; margin-top: 8px; overflow-x: auto; overflow-y: hidden; -webkit-overflow-scrolling: touch; scrollbar-width: thin; scrollbar-color: #2a2e36 transparent; padding-bottom: 2px; } .net-chart-options::-webkit-scrollbar { height: 4px; } .net-chart-options::-webkit-scrollbar-track { background: transparent; } .net-chart-options::-webkit-scrollbar-thumb { background: #2a2e36; border-radius: 2px; } /* 단위 토글(일/주/월/연) — 기간 토글 바로 아래에 분리 표시. */ .net-chart-options.nw-units { margin-top: 4px; } .nw-chip { appearance: none; background: transparent; color: #c9ccd3; border: 1px solid #2a2e36; border-radius: 999px; padding: 5px 12px; font-size: 11px; cursor: pointer; font-variant-numeric: tabular-nums; line-height: 1; flex: none; white-space: nowrap; } .nw-chip:hover, .nw-chip:active { border-color: #3a3f4a; color: #f0f0f0; } .nw-chip.active { background: #2a2e36; color: #f0f0f0; border-color: #4a4f5a; font-weight: 600; } .net-chart-foot-r { display: inline-flex; align-items: center; gap: 6px; } .nw-nav { appearance: none; background: transparent; color: #c9ccd3; border: 1px solid #2a2e36; border-radius: 4px; width: 22px; height: 22px; padding: 0; font-size: 9px; line-height: 1; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; } .nw-nav:hover, .nw-nav:active { color: #f0f0f0; border-color: #3a3f4a; background: #1a1d24; } .net-chart-svg { display: block; width: 100%; height: auto; font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; touch-action: pan-y; cursor: crosshair; } .net-chart-svg .grid { stroke: #353b4a; stroke-width: 1; stroke-dasharray: 3 3; } .net-chart-svg .period-sep { stroke: #4a5266; stroke-width: 1; stroke-dasharray: 2 4; opacity: 0.85; } .net-chart-svg .zero-line { stroke: #8b93a7; stroke-width: 1.2; stroke-dasharray: 5 3; opacity: 0.9; } .net-chart-svg .pt-dot { stroke: #0b0d12; stroke-width: 1.2; } .net-chart-svg .axis { fill: #6b7280; font-size: 14px; font-variant-numeric: tabular-nums; } .net-chart-svg .area { stroke: none; } .net-chart-svg .line { fill: none; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; } .net-chart-svg .line.dashed { stroke-dasharray: 5 4; stroke-opacity: 0.85; } .net-chart-svg .last-dot { stroke: #0b0d12; stroke-width: 1.5; } .net-chart-svg .peak-dot { stroke: #0b0d12; stroke-width: 1.5; } .net-chart-svg .last-dot.live { stroke: #0b0d12; stroke-width: 2; animation: live-pulse 1.6s ease-in-out infinite; } .net-chart-svg .live-halo { animation: live-halo 1.6s ease-in-out infinite; } .net-chart-svg .ath-ring { animation: ath-pulse 1.8s ease-in-out infinite; } .net-chart-svg .ath-label { paint-order: stroke; stroke: #0b0d12; stroke-width: 2.5; stroke-linejoin: round; } @keyframes live-pulse { 0%, 100% { r: 3.6; } 50% { r: 4.4; } } @keyframes live-halo { 0%, 100% { fill-opacity: 0.18; r: 6; } 50% { fill-opacity: 0.32; r: 8; } } @keyframes ath-pulse { 0%, 100% { r: 7; stroke-opacity: 0.95; } 50% { r: 8.5; stroke-opacity: 0.55; } } .net-chart-svg .nw-hover-line { stroke: #d6d8dd; stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.7; } .net-chart-svg .nw-hover-dot { fill: #f0f0f0; stroke: #0b0d12; stroke-width: 1.5; } .net-chart-tip { position: absolute; top: 30px; pointer-events: none; background: rgba(20, 22, 28, 0.94); border: 1px solid #2a2e36; border-radius: 6px; padding: 6px 24px 6px 8px; font-size: 11px; color: #d6d8dd; opacity: 0; transition: opacity 0.12s ease; white-space: nowrap; z-index: 5; min-width: 140px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); } .tip-close { position: absolute; top: 2px; right: 2px; appearance: none; background: transparent; border: none; color: #6b7280; font-size: 11px; line-height: 1; width: 18px; height: 18px; padding: 0; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; border-radius: 3px; pointer-events: auto; } .tip-close:hover, .tip-close:active { color: #f0f0f0; background: rgba(255,255,255,0.08); } .net-chart-tip .tip-date { font-weight: 600; color: #f0f0f0; font-variant-numeric: tabular-nums; margin-bottom: 4px; font-size: 11px; } .net-chart-tip .tip-row { display: flex; justify-content: space-between; gap: 12px; font-variant-numeric: tabular-nums; } .net-chart-tip .tip-row[hidden] { display: none; } .net-chart-tip .tip-row + .tip-row { margin-top: 2px; } .net-chart-tip .tip-row .muted { color: #6b7280; font-weight: 400; } .net-chart-tip .tip-row b { font-weight: 600; } .net-chart-tip .up { color: #ff4d5e; } .net-chart-tip .down { color: #4a8cf0; } .net-chart-tip .neutral { color: #c9ccd3; } .net-chart-foot { display: flex; justify-content: space-between; align-items: baseline; margin-top: 2px; font-size: 11px; } .net-chart-foot .muted { color: #6b7280; } .net-chart-foot .net-chart-last { color: #f0f0f0; font-weight: 600; font-variant-numeric: tabular-nums; } .chart-empty { margin: 12px 14px 0; padding: 12px; background: #11141c; border: 1px solid #1f2330; border-radius: 10px; font-size: 12px; color: #8b8f9a; text-align: center; } /* 시장 상황 카드 — 자산정보 탭의 시장정보 sub-tab 내부 (ADR + 투자자별 매매). */ .market-card { margin: 14px 14px 8px; padding: 14px 16px; background: #11141c; border: 1px solid #1f2330; border-radius: 10px; } .market-card-error { font-size: 12px; color: #8b8f9a; text-align: center; padding: 12px; } .market-card-title { font-size: 15px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em; margin-bottom: 10px; display: flex; align-items: baseline; gap: 8px; } .market-card .market-date { font-size: 11px; color: #5a5f6c; font-weight: 400; font-variant-numeric: tabular-nums; } .market-refresh-btn { margin-left: auto; padding: 2px 9px; background: #1a1d24; color: #c9ccd3; border: 1px solid #2a2f3a; border-radius: 999px; cursor: pointer; font-family: inherit; font-size: 13px; line-height: 1; font-weight: 600; transition: background 0.15s, color 0.15s; } .market-refresh-btn:hover, .market-refresh-btn:active { background: #2a2f3a; color: #f0f0f0; } .market-refresh-btn.spinning { animation: market-spin 0.6s linear infinite; } @keyframes market-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .market-idx-info { margin-left: 6px; padding: 0 5px; background: transparent; color: #6b7280; border: 1px solid #2a2f3a; border-radius: 50%; font-family: inherit; font-size: 10px; line-height: 16px; cursor: pointer; vertical-align: middle; font-weight: 600; transition: color 0.15s, border-color 0.15s, background 0.15s; } .market-idx-info:hover { color: #ff8a95; border-color: #ff8a95; background: rgba(255,138,149,0.08); } .idx-info-body { padding: 14px 16px; font-size: 14px; color: #c9ccd3; line-height: 1.6; letter-spacing: -0.01em; } .idx-info-stats { display: flex; align-items: baseline; gap: 12px; padding-bottom: 10px; margin-bottom: 10px; border-bottom: 1px solid #1f2330; font-variant-numeric: tabular-nums; } .idx-info-price { font-size: 22px; font-weight: 700; color: #f0f0f0; } .idx-info-change { font-size: 14px; font-weight: 600; } .idx-info-change.up { color: #ff4d5e; } .idx-info-change.down { color: #4a8cf0; } .idx-info-change.flat { color: #8b8f9a; } .idx-info-desc { font-size: 13px; color: #c9ccd3; white-space: pre-line; line-height: 1.7; } .idx-info-chart { margin: 0 0 12px; padding: 8px 10px; background: #0e1117; border: 1px solid #1f2330; border-radius: 8px; position: relative; } .idx-chart-svg { display: block; width: 100%; height: auto; cursor: crosshair; } .idx-chart-svg .idx-axis { fill: #6b7280; font-size: 9px; font-variant-numeric: tabular-nums; } .idx-chart-svg .idx-grid { stroke: #1f2330; stroke-width: 1; stroke-dasharray: 2 3; } .idx-chart-svg .idx-hover-line { stroke: #d6d8dd; stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.75; } .idx-chart-svg .idx-hover-dot { stroke: #0b0d12; stroke-width: 1.5; } .idx-chart-tip { position: absolute; top: 6px; right: 14px; background: #14171f; border: 1px solid #2a2f3a; border-radius: 6px; padding: 4px 8px; font-size: 11px; color: #f0f0f0; display: flex; gap: 8px; align-items: center; font-variant-numeric: tabular-nums; } .idx-chart-tip[hidden] { display: none; } .idx-chart-tip .tip-d { color: #8b8f9a; pointer-events: none; } .idx-chart-tip .tip-v { font-weight: 600; pointer-events: none; } .idx-chart-tip .tip-close { position: static; width: auto; height: auto; background: transparent; color: #8b8f9a; border: 0; padding: 0 4px; cursor: pointer; font-family: inherit; font-size: 14px; line-height: 1; margin-left: 4px; display: inline-flex; align-items: center; } .idx-chart-tip .tip-close:hover { color: #ff8a95; background: transparent; } .idx-chart-loading, .idx-chart-err { padding: 24px 0; text-align: center; font-size: 12px; color: #8b8f9a; } .idx-chart-err { color: #ff8a95; } .idx-chart-foot { display: flex; justify-content: space-between; margin-top: 6px; font-size: 11px; color: #5a5f6c; font-variant-numeric: tabular-nums; } .market-section { margin-top: 10px; } .market-section:first-of-type { margin-top: 0; } .market-section-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; } .market-section-label { font-size: 12px; color: #d6d8dd; font-weight: 600; } .market-section-hint { font-size: 10px; color: #5a5f6c; } table.market-table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; } table.market-table th { text-align: left; font-weight: 400; color: #8b8f9a; padding: 4px 8px 4px 0; font-size: 11px; vertical-align: middle; } table.market-table td { padding: 4px 0; font-size: 13px; color: #e6e6e6; font-weight: 500; text-align: right; } table.market-table thead th { text-align: right; font-size: 10px; color: #6b7280; padding-bottom: 2px; } table.market-table thead th:first-child { text-align: left; } table.market-table .up { color: #ff4d5e; font-weight: 600; } table.market-table .down { color: #4a8cf0; font-weight: 600; } table.market-table .flat { color: #c9ccd3; } table.market-table .neutral { color: #8b8f9a; } table.market-adr th { width: 64px; } table.market-adr td.adr-val { width: 88px; } table.market-adr td.adr-val b { font-size: 14px; } table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; } .adr-tag { display: inline-block; padding: 1px 6px; border-radius: 999px; font-size: 9px; font-weight: 600; letter-spacing: 0.04em; margin-left: 4px; vertical-align: middle; } .adr-tag.up { background: rgba(255,77,94,0.16); color: #ff8a96; border: 1px solid rgba(255,77,94,0.3); } .adr-tag.down { background: rgba(74,140,240,0.16); color: #8db4f4; border: 1px solid rgba(74,140,240,0.3); } .adr-tag.flat { background: #1a1d24; color: #8b8f9a; border: 1px solid #2a2e36; } .adr-sub { margin-top: 2px; font-size: 10px; line-height: 1.1; color: #8b8f9a; } @media (max-width: 600px) { .market-card { margin: 10px 10px 6px; padding: 12px 14px; } table.market-table td { font-size: 12px; } table.market-adr th { width: 56px; font-size: 11px; } table.market-adr td.adr-breakdown { font-size: 10px; } } /* ADR 추세 카드 — 코스피·코스닥 한 SVG 안에 통합. 시장정보 sub-tab 내부. */ .adr-trend-card .market-card-sub { font-size: 11px; color: #5a5f6c; font-weight: 400; margin-left: 4px; } .adr-trend-card .market-section-hint { margin-bottom: 8px; } .adr-combo-meta { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin: 8px 0 4px; font-variant-numeric: tabular-nums; } .adr-legend { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: #d6d8dd; font-weight: 500; } .adr-legend-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; } .adr-combo-range { margin-left: auto; } .adr-trend-row { position: relative; margin-top: 6px; touch-action: pan-y; } .adr-trend-svg { width: 100%; height: 100px; display: block; font-variant-numeric: tabular-nums; } /* 호버 인디케이터 — 세로선 1 + 시장당 점. net-chart nw-hover 패턴 확장. */ .adr-trend-svg .adr-hover { transition: opacity 0.1s; } .adr-trend-svg .adr-hover-line { stroke: #d6d8dd; stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.7; } .adr-trend-svg .adr-hover-dot { stroke-width: 1.2; } .adr-trend-tip { position: absolute; background: #11141c; border: 1px solid #2a2e36; border-radius: 6px; padding: 5px 8px; font-size: 11px; color: #d6d8dd; pointer-events: none; opacity: 0; transition: opacity 0.1s; z-index: 5; white-space: nowrap; min-width: 110px; } .adr-trend-tip .tip-d { font-weight: 600; color: #f0f0f0; font-variant-numeric: tabular-nums; margin-bottom: 2px; } .adr-trend-tip .tip-line { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; font-variant-numeric: tabular-nums; } .adr-trend-tip .tip-line[hidden] { display: none; } .adr-trend-tip .tip-mk { font-size: 10px; color: #8b8f9a; } .adr-trend-tip .tip-kospi .tip-mk { color: #fbbf24; } .adr-trend-tip .tip-kosdaq .tip-mk { color: #34d399; } .adr-trend-tip .tip-v { font-weight: 700; font-variant-numeric: tabular-nums; } .adr-trend-tip .tip-v.up { color: #ff8a96; } .adr-trend-tip .tip-v.down { color: #8db4f4; } .adr-trend-tip .tip-v.flat { color: #d6d8dd; } .adr-trend-tip .tip-v.live::after { content: " *"; color: #fbbf24; font-weight: 600; } .adr-trend-empty { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 8px 0; } .adr-trend-controls { display: flex; gap: 6px; margin: 8px 0 4px; flex-wrap: wrap; } .adr-range-btn { appearance: none; -webkit-appearance: none; background: #11141c; color: #c9ccd3; border: 1px solid #2a2e36; border-radius: 8px; padding: 5px 11px; font-size: 11px; font-weight: 500; font-family: inherit; cursor: pointer; font-variant-numeric: tabular-nums; } .adr-range-btn:hover, .adr-range-btn:active { background: #1a1d26; color: #f0f0f0; } .adr-range-btn.active { background: rgba(251,191,36,0.12); color: #fbbf24; border-color: rgba(251,191,36,0.4); } .adr-range-btn:focus-visible { outline: 1px solid #4a4f5a; outline-offset: 1px; } .adr-live-badge { display: inline-block; margin-left: 6px; font-size: 9px; font-weight: 600; letter-spacing: 0.04em; color: #fbbf24; } /* 자산정보 탭 내부 sub-tab — 자산보기/차트보기. 상단 배치는 밑줄 강조, 하단 배치는 일반 버튼 형태. */ .sub-tabs { display: flex; gap: 4px; margin: 8px 14px 0; border-bottom: 1px solid #1f2330; } .sub-tab { appearance: none; background: transparent; color: #8b8f9a; border: 0; border-bottom: 2px solid transparent; padding: 8px 14px; font-size: 13px; cursor: pointer; font-family: inherit; font-weight: 500; } .sub-tab:hover, .sub-tab:active { color: #d6d8dd; } .sub-tab.active { color: #f0f0f0; border-bottom-color: #ff4d5e; } .sub-tab-panel[hidden] { display: none; } /* 하단 배치 — 탭 강조선 제거하고 둥근 버튼 형태. */ .sub-tabs.sub-tabs-bottom { margin: 14px 14px; border: 0; padding: 0; gap: 8px; justify-content: center; } .sub-tabs.sub-tabs-bottom .sub-tab { border: 1px solid #2a2e36; border-radius: 8px; background: #11141c; color: #c9ccd3; padding: 8px 18px; } .sub-tabs.sub-tabs-bottom .sub-tab:hover, .sub-tabs.sub-tabs-bottom .sub-tab:active { background: #1a1d26; color: #f0f0f0; } .sub-tabs.sub-tabs-bottom .sub-tab.active { background: #2a1820; color: #ff8a96; border-color: rgba(255,77,94,0.32); } /* 합산 / 각계좌별도 단일 토글 버튼 — 상단 자동토글 옆 1곳에만 배치. 클릭마다 두 상태 swap. data-account-view-state 속성으로 현재 상태 표시. 기본(합산)은 중성색, 각계좌별도일 때 강조. */ .account-view-toggle-btn { flex: none; display: inline-flex; align-items: center; justify-content: center; min-width: 36px; height: 36px; padding: 0 10px; background: #1c2230; border: 1px solid #2a3142; color: #e6e6e6; border-radius: 8px; font-size: 17px; line-height: 1; cursor: pointer; font-family: inherit; user-select: none; transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s; } .account-view-toggle-btn:hover { background: #232a3b; } .account-view-toggle-btn:active { transform: scale(0.95); } .account-view-toggle-btn[data-account-view-state="by-account"] { background: rgba(255,77,94,0.15); border-color: #ff4d5e; color: #ff4d5e; } .account-view-pane[hidden] { display: none; } .holdings-toggle-wrap { margin-top: 4px; } /* 차트 컨트롤 — 자산정보 탭 상단의 공통 기간·단위 select 묶음. */ .chart-controls { display: flex; gap: 10px; margin: 10px 14px 4px; flex-wrap: wrap; } .chart-controls .cc-field { display: flex; align-items: center; gap: 6px; } .chart-controls .cc-label { color: #8b8f9a; font-size: 11px; } .chart-controls .nw-select { appearance: none; -webkit-appearance: none; background: #11141c url("data:image/svg+xml;utf8,") no-repeat right 8px center; background-size: 10px; color: #d6d8dd; border: 1px solid #2a2e36; border-radius: 8px; padding: 6px 26px 6px 10px; font-size: 12px; cursor: pointer; font-family: inherit; min-width: 96px; } .chart-controls .nw-select:focus { outline: 1px solid #4a4f5a; outline-offset: 0; } @media (max-width: 600px) { nav.tabs .tab-label { padding: 10px 12px; font-size: 12px; } .owner-card { margin: 10px 10px 6px; padding: 12px 14px; } .owner-card table.kpi th { width: auto; font-size: 11px; } .owner-card table.kpi td { font-size: 12px; } .cash-list { margin: 4px 10px 10px; font-size: 11px; } .net-chart { margin: 10px 10px 0; padding: 8px 10px 6px; } /* 모바일은 viewBox(800) > viewport 라 scale 0.5 정도 — viewBox unit 폰트를 크게 잡아 화면에서 적당히 보이게. */ .net-chart-svg .axis { font-size: 22px; } } /* 주문 모달 (order-modal) — 거래내역 보기용 trade-modal과 별개. 모달 박스 자체 스크롤 X (호가창만 스크롤) */ .order-box { max-width: 560px; overflow-y: hidden; display: flex; flex-direction: column; } .order-box > [data-order-step="1"] { display: flex; flex-direction: column; flex: 1; min-height: 0; } .order-symbol-select { background: #14171f; color: #f0f0f0; border: 1px solid #1f2330; border-radius: 6px; padding: 7px 9px; font-size: 14px; font-weight: 600; font-family: inherit; width: 100%; max-width: 360px; cursor: pointer; color-scheme: dark; } .order-symbol-select:focus { outline: none; border-color: #ff4d5e; } .order-symbol-select option { background: #14171f; color: #f0f0f0; } /* native select 화살표(chevron) — 다크 테마로 밝은 색 강제 */ .order-inputs select, .open-orders-box select { color-scheme: dark; } .order-meta { display: flex; gap: 8px; align-items: baseline; margin-bottom: 12px; flex-wrap: nowrap; } .order-meta .cur-price { font-size: clamp(14px, 4.5vw, 22px); font-weight: 700; color: #f0f0f0; font-variant-numeric: tabular-nums; white-space: nowrap; flex-shrink: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; } .order-meta .change { font-size: clamp(10px, 2.8vw, 13px); font-variant-numeric: tabular-nums; white-space: nowrap; } .order-meta .change.up { color: #ff5b69; } .order-meta .change.down { color: #5b9bff; } .order-market-phase { font-size: 10px; padding: 2px 7px; border-radius: 4px; margin-left: auto; font-variant-numeric: tabular-nums; } .order-market-phase.regular { background: rgba(31,90,40,0.35); color: #8aff95; border: 1px solid rgba(31,90,40,0.6); } .order-market-phase.nxt { background: rgba(86,42,138,0.35); color: #d3a7ff; border: 1px solid rgba(86,42,138,0.6); } .order-market-phase.closed, .order-market-phase.holiday, .order-market-phase.weekend { background: rgba(90,31,40,0.35); color: #ff8a95; border: 1px solid rgba(90,31,40,0.6); } .order-side-toggle { display: flex; gap: 0; margin-bottom: 12px; border-radius: 8px; overflow: hidden; border: 1px solid #1f2330; } .order-side-toggle button { flex: 1; padding: 10px 12px; background: #14171f; color: #c9ccd3; border: 0; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border-right: 1px solid #1f2330; } .order-side-toggle button:last-child { border-right: 0; } .order-side-toggle button.active[data-side="BUY"] { background: #c9303e; color: #fff; } .order-side-toggle button.active[data-side="SELL"] { background: #2a5fb3; color: #fff; } .order-side-toggle button.info-tab { color: #8db4ff; max-width: 100px; } .order-side-toggle button.info-tab:hover { background: #1f2840; color: #c9e0ff; } /* 좌우 2-컬럼 강제 (호가창 좁게, 인풋 영역 넓게). flex로 grid 안 남은 공간 채워 stretch */ .order-body { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.25fr); gap: 10px; align-items: stretch; flex: 1; min-height: 0; } /* 호가창만 폰트 ~70% 압축, 별도 스크롤. height 100%로 인풋 영역과 동일 stretch */ .orderbook { display: flex; flex-direction: column; justify-content: center; border: 1px solid #1f2330; border-radius: 6px; overflow-y: auto; overflow-x: hidden; height: 100%; min-height: 0; scrollbar-width: thin; } .orderbook::-webkit-scrollbar { width: 4px; } .orderbook::-webkit-scrollbar-thumb { background: #2a2e3a; border-radius: 2px; } .orderbook .ob-row { display: grid; grid-template-columns: 1fr 1fr; padding: 1px 5px; font-size: 7.5px; line-height: 1.35; font-variant-numeric: tabular-nums; cursor: pointer; border-bottom: 1px solid #15171e; transition: box-shadow 0.08s; } .orderbook .ob-row:last-of-type { border-bottom: 0; } .orderbook .ob-row.ask { background: linear-gradient(to left, rgba(201,48,62,0.16), transparent 60%); color: #ff8a95; } .orderbook .ob-row.bid { background: linear-gradient(to left, rgba(42,95,179,0.16), transparent 60%); color: #8db4ff; } .orderbook .ob-row:hover { background: #1c1f29; } .orderbook .ob-row.selected { box-shadow: inset 0 0 0 1.5px #ffcc33; color: #fff !important; font-weight: 700; background-color: rgba(255,204,51,0.18) !important; } .orderbook .ob-divider { padding: 2px 5px; font-size: 8.5px; font-weight: 700; color: #f0f0f0; background: #161922; text-align: center; border-top: 1px solid #1f2330; border-bottom: 1px solid #1f2330; font-variant-numeric: tabular-nums; } .orderbook .ob-row .price { text-align: left; } .orderbook .ob-row .qty { text-align: right; opacity: 0.8; font-size: 7px; } .price-tick-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-top: 4px; } .price-tick-buttons button { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 5px; padding: 5px 4px; font-size: 11px; cursor: pointer; font-family: inherit; } .price-tick-buttons button:hover { border-color: #ff4d5e; color: #f0f0f0; } .price-tick-buttons button:active { transform: translateY(1px); } .qty-step-buttons { display: grid; grid-template-columns: repeat(5, 1fr); gap: 3px; margin-top: 4px; } .qty-step-buttons button { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 5px; padding: 5px 2px; font-size: 10px; cursor: pointer; font-family: inherit; } .qty-step-buttons button:hover { border-color: #ff4d5e; color: #f0f0f0; } .qty-step-buttons button:active { transform: translateY(1px); } .qty-step-buttons button[data-qty-step="100%"], .qty-step-buttons button[data-qty-step="max"] { background: #2a1f24; color: #ffcc77; } .qty-step-buttons button[data-qty-step="clear"] { background: #1d1418; color: #ff8a95; } .order-inputs input:disabled, .order-inputs select:disabled { opacity: 0.5; cursor: not-allowed; } .order-inputs { display: flex; flex-direction: column; gap: 8px; min-height: 0; } .order-inputs .order-actions { margin-top: auto; } .order-inputs label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: #8b8f9a; } .order-inputs label.inline-row { flex-direction: row; align-items: center; gap: 8px; } .order-inputs label.inline-row > select { flex: 1; min-width: 0; } .order-inputs input, .order-inputs select { background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 9px 10px; color: #e6e6e6; font-size: 16px; font-family: inherit; } .order-inputs input:focus, .order-inputs select:focus { outline: none; border-color: #ff4d5e; } .order-inputs .max-qty { font-size: 10.5px; color: #8b8f9a; font-weight: 400; margin-left: 4px; } .order-info { font-size: clamp(9.5px, 2.6vw, 12px); color: #c9ccd3; padding: 8px 10px; background: #11141b; border: 1px solid #1f2330; border-radius: 6px; line-height: 1.55; word-break: keep-all; } .order-info b { white-space: nowrap; } .order-info .pl-up { color: #ff8a95; white-space: nowrap; } .order-info .pl-down { color: #8db4ff; white-space: nowrap; } .order-info b.qty-over { color: #ff8a95; } input[data-order-qty].qty-over { color: #ff8a95; } .order-modal-msg { padding: 12px; margin-bottom: 12px; border-radius: 8px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; font-variant-numeric: tabular-nums; } .order-modal-msg.error { background: #2a1418; border: 1px solid #5a1f28; color: #ff8a95; } .order-modal-msg.success { background: #142a18; border: 1px solid #1f5a28; color: #8aff95; } .order-modal-msg.info { background: #14171f; border: 1px solid #1f2330; color: #c9ccd3; } .order-modal-msg.hidden { display: none; } .order-pin-row { display: flex; flex-direction: column; gap: 6px; margin: 12px 0; } .order-pin-row label { font-size: 11px; color: #8b8f9a; } .order-pin-row input { background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 12px 14px; color: #e6e6e6; font-size: 22px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.2em; text-align: center; } .order-pin-row input:focus { outline: none; border-color: #ff4d5e; } .order-countdown { font-size: 13px; color: #8b8f9a; margin-top: 6px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; transition: color 0.15s; } .order-countdown.warn { color: #ffcc77; } .order-countdown.danger { color: #ff5b69; } .order-actions { display: flex; gap: 8px; margin-top: 14px; } .order-actions button { flex: 1; padding: 11px 14px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border: 0; } .order-actions .btn-cancel { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; } .order-actions .btn-confirm { background: #c9303e; color: #fff; } .order-actions .btn-confirm[data-side="SELL"] { background: #2a5fb3; } .order-actions button:active { transform: translateY(1px); } .order-actions button:disabled { opacity: 0.5; cursor: not-allowed; } .sub-tab-order-btn { margin-left: auto; background: #2a1418; color: #ff8a95; border: 1px solid rgba(255,138,149,0.45); border-radius: 6px; padding: 6px 14px; font-size: 12px; font-weight: 700; cursor: pointer; font-family: inherit; } .sub-tab-order-btn:hover { background: rgba(255,138,149,0.16); border-color: #ff8a95; color: #fff; } .sub-tab-order-btn:active { transform: translateY(1px); } .pin-box { max-width: 400px; } .pin-box > .hidden { display: none; } .open-orders-box { max-width: 560px; } .open-orders-list { display: flex; flex-direction: column; gap: 8px; max-height: 50dvh; overflow-y: auto; margin-bottom: 12px; } .open-orders-list .empty { padding: 32px 12px; text-align: center; color: #8b8f9a; font-size: 12px; } .open-order-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 10px 12px; background: #14171f; border: 1px solid #1f2330; border-radius: 8px; align-items: center; } .open-order-row.active-card { border-color: rgba(255,204,51,0.45); background: rgba(255,204,51,0.06); } .open-order-row .row-info { display: flex; flex-direction: column; gap: 3px; min-width: 0; } .open-order-row .row-line1 { display: flex; gap: 6px; align-items: baseline; font-size: 13px; font-weight: 600; color: #f0f0f0; } .open-order-row .row-line1 .badge-side { font-size: 10px; padding: 1px 6px; border-radius: 3px; font-weight: 700; } .open-order-row .row-line1 .badge-side.buy { background: #c9303e; color: #fff; } .open-order-row .row-line1 .badge-side.sell { background: #2a5fb3; color: #fff; } .open-order-row .row-line1 .badge-side.pin { background: #ffcc33; color: #14171f; } .open-order-row .row-line2 { font-size: 11px; color: #8b8f9a; font-variant-numeric: tabular-nums; } .open-order-row .row-action button { background: #2a1418; color: #ff8a95; border: 1px solid rgba(255,138,149,0.35); border-radius: 6px; padding: 6px 12px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; } .open-order-row .row-action button:hover { background: rgba(255,138,149,0.14); border-color: #ff8a95; } .open-order-row .row-action button:disabled { opacity: 0.5; cursor: not-allowed; } .open-order-row.active-card .row-action button { background: rgba(255,204,51,0.12); color: #ffcc77; border-color: rgba(255,204,51,0.45); } .open-order-row.active-card .row-action button:hover { background: rgba(255,204,51,0.25); } /* 매매 거부·검증 에러 토스트 — 화면 하단, 자동 사라짐 */ .order-toast { position: fixed; left: 50%; bottom: 80px; transform: translateX(-50%); z-index: 2000; background: #2a1418; border: 1px solid #5a1f28; color: #ff8a95; padding: 12px 18px; border-radius: 8px; font-size: 13px; max-width: 90vw; box-shadow: 0 6px 20px rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.2s; pointer-events: none; white-space: pre-wrap; line-height: 1.55; font-variant-numeric: tabular-nums; } .order-toast.show { opacity: 1; } .order-toast.success { background: #142a18; border-color: #1f5a28; color: #8aff95; } .order-toast.info { background: #14171f; border-color: #1f2330; color: #c9ccd3; } ''' OWNER_TAB_IDS = {'본인': 'self', '가희': 'gahee'} def _owner_tab_id(owner: str) -> str: return OWNER_TAB_IDS.get(owner, 'owner-' + owner.encode('ascii', 'replace').decode().replace('?', 'x')) def _owner_full_title(owner: str) -> str: """stock_portfolio_report.OWNER_LABELS를 단일 진실 출처로 사용 (drift 방지).""" from stock_portfolio_report import OWNER_LABELS return OWNER_LABELS.get(owner, f'{owner} 자산') def _render_chart_controls(selected_days: int, selected_unit: str, selected_mode: str = NET_WORTH_CHART_MODE) -> str: """차트용 공통 컨트롤 — 기간 / 단위 / 모드(순자산·손익누적·기간손익) select 3개. 자산정보 탭 상단에 1세트만 노출, 본인·가희 차트 둘 다 동시 컨트롤. 의미없는 단위(이번주+월별 등)는 disabled.""" allowed_units = NET_WORTH_ALLOWED_UNITS_BY_DAYS.get( selected_days, tuple(u for _, u in NET_WORTH_UNIT_PRESETS) ) range_opts = ''.join( f'' for lbl, d in NET_WORTH_CHART_PRESETS ) unit_opts = ''.join( f'' for lbl, u in NET_WORTH_UNIT_PRESETS ) mode_opts = ''.join( f'' for lbl, m in NET_WORTH_MODE_PRESETS ) return ( '
    ' f'' # 단위·모드 한 cc-field 묶음 — 좁은 화면 줄바꿈 방지 + 시각적 인접 배치. f'' '
    ' ) def _format_signed_billion(v: int | None) -> tuple[str, str]: """투자자 매매 금액 포맷 — (표시문자열, direction 클래스). 단위는 억원.""" if v is None: return '—', 'flat' if v > 0: return f'+{v:,}', 'up' if v < 0: return f'{v:,}', 'down' return '0', 'flat' def _adr_class(adr: float | None) -> tuple[str, str]: """ADR 색상·해석 라벨. 통상 ≤75 침체(매수권), ≥125 과열(매도권). 20일 이동평균 기준 — 단일일 raw ratio는 변동성이 커서 임계치 해석이 무의미.""" if adr is None: return 'flat', '' if adr <= 75: return 'down', '침체' if adr >= 125: return 'up', '과열' return 'flat', '' def _build_adr_series_with_live(history_rows: list[dict], live_pt: dict | None) -> list[dict]: """history(오래된 순) + 오늘 라이브 1점을 합쳐 [{date,adr,is_live}, ...] 반환. 라이브는 history 마지막 date 와 다를 때만 append (21:00 누적 후엔 자동 중복).""" base: list[dict] = [ {'date': r['date'], 'adr': float(r['adr']), 'is_live': False} for r in history_rows if r.get('adr') is not None and r.get('date') ] if live_pt and live_pt.get('adr') is not None and live_pt.get('date'): last_date = base[-1]['date'] if base else None if live_pt['date'] != last_date: base.append({'date': live_pt['date'], 'adr': float(live_pt['adr']), 'is_live': True}) return base def _compute_adr_ma_latest(symbol: str, today_raw_adr: float | None, today_date: str | None, window: int = ADR_MA_WINDOW) -> tuple[float | None, int, bool]: """심볼별 최근 N일 이평. (avg or None, available_count, used_live). available_count < window 이면 avg=None (= "누적 중 count/window").""" history = _load_market_history(-1).get(symbol, []) live_pt = ({'date': today_date, 'adr': today_raw_adr} if today_raw_adr is not None and today_date else None) base = _build_adr_series_with_live(history, live_pt) if not base: return (None, 0, False) used = base[-window:] used_live = any(r.get('is_live') for r in used) if len(used) < window: return (None, len(used), used_live) avg = sum(r['adr'] for r in used) / len(used) return (avg, len(used), used_live) def _compute_adr_ma_series(rows: list[dict], window: int = ADR_MA_WINDOW) -> list[tuple[str, float, bool]]: """rows(오래된 순, {date,adr,is_live}) → 각 시점 trailing window 평균 시리즈. [(date, ma, is_live), ...]. window 채워지지 않은 앞쪽은 skip. is_live는 그 시점 윈도우에 라이브 점이 포함됐는지 (= 마지막 평균만 라이브일 가능성).""" series: list[tuple[str, float, bool]] = [] for i in range(window - 1, len(rows)): win = rows[i + 1 - window : i + 1] avg = sum(r['adr'] for r in win) / window is_live = any(r.get('is_live') for r in win) series.append((rows[i]['date'], avg, is_live)) return series def _render_adr_summary_block(rows: list[dict] | None) -> str: """ADR 20일 이평 표 + 임계치 hint. ADR 추세 카드 상단 mini section. rows가 비면 안내 텍스트로 graceful degrade — 차트 카드 자체는 계속 그려진다.""" if not rows: return ( '
    ' + '
    시장 지표 미수신
    ' + '
    ' ) body_rows: list[str] = [] for r in rows: raw_adr = r.get('adr') bd = r.get('bizdate') today_iso = f'{bd[:4]}-{bd[4:6]}-{bd[6:]}' if (bd and len(bd) == 8) else None ma, count, _used_live = _compute_adr_ma_latest(r['symbol'], raw_adr, today_iso) adr_cls, adr_tag = _adr_class(ma) if ma is not None: adr_str = f'{ma:.1f}' sub_html = '
    20일 이평
    ' else: adr_str = '—' sub_html = f'
    누적 중 {count}/{ADR_MA_WINDOW}
    ' tag_html = f' {adr_tag}' if adr_tag else '' body_rows.append( f'' f'{html.escape(r["label"])}' f'{adr_str}{tag_html}{sub_html}' f'{r["rise"]:,}↑ / {r["fall"]:,}↓ / {r["steady"]:,}→' f'' ) return ( '
    ' + f'{"".join(body_rows)}
    ' + '
    ' ) 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: live_by_market[sym] = {'adr': adr, 'date': iso} # 시장별 trailing 20일 이평 시리즈 — (date, ma, is_live). # raw 시리즈(history+오늘 라이브)에서 시점별 trailing 평균 산출 후, days 파라미터로 마지막 N개 trim. market_pts: dict[str, list[tuple[str, float, bool]]] = {} raw_count_max = 0 for sym in ('KOSPI', 'KOSDAQ'): raw_rows = history_all.get(sym, []) live_pt = live_by_market.get(sym) base = _build_adr_series_with_live(raw_rows, live_pt) raw_count_max = max(raw_count_max, len(base)) ma_series = _compute_adr_ma_series(base) if days >= 0 and len(ma_series) > days: ma_series = ma_series[-days:] market_pts[sym] = ma_series summary_block = _render_adr_summary_block(live_rows) # 두 시장 합쳐 점이 하나도 없으면 누적 부족 안내. all_dates = sorted({d for pts in market_pts.values() for d, _, _ in pts if d}) if not all_dates: sel_label = next((lbl for lbl, d in ADR_TREND_PRESETS if d == days), '맞춤') hint = (f'{ADR_MA_WINDOW}일 누적 중 ({raw_count_max}/{ADR_MA_WINDOW}) — ' f'매 영업일 21:00 raw ADR 적재, {ADR_MA_WINDOW}일 채워지면 이평선 표시') return ( '
    ' f'
    ADR 추세 {ADR_MA_WINDOW}일 이평 · ' + html.escape(sel_label) + '
    ' + summary_block + f'
    {html.escape(hint)}
    ' + _render_adr_trend_controls(days) + '
    ' ) date_to_i = {d: i for i, d in enumerate(all_dates)} n_dates = len(all_dates) # 차트 면적 — 두 라인 + 가이드 + 마커 라벨 여유. height는 sparkline 단일 70 대비 95 로 확장. Y_MIN, Y_MAX = 0.0, 200.0 W, H = 360.0, 95.0 PAD_L, PAD_R, PAD_T, PAD_B = 4.0, 38.0, 10.0, 14.0 PLOT_W = W - PAD_L - PAD_R PLOT_H = H - PAD_T - PAD_B def x_for(i: int) -> float: if n_dates == 1: return PAD_L + PLOT_W / 2 return PAD_L + (PLOT_W * i / (n_dates - 1)) def y_for(v: float) -> float: v = max(Y_MIN, min(Y_MAX, v)) return PAD_T + PLOT_H * (1 - (v - Y_MIN) / (Y_MAX - Y_MIN)) # 가이드선 — 75/100/125. 두 라인 위에 깔리지만 흐려서 노이즈 적음. y75, y100, y125 = y_for(75), y_for(100), y_for(125) guides = ( f'' f'' f'' f'100' f'75' f'125' ) # 시장별 이평선 path + 마지막 마커. 이평은 매끄러운 단일 시리즈라 saved/live 분리 점선 불필요. # 마지막 점이 오늘 라이브 raw를 포함한 평균이면 (is_live) open circle + 별표 마커. line_parts: list[str] = [] marker_parts: list[str] = [] for sym in ('KOSPI', 'KOSDAQ'): pts = market_pts[sym] if not pts: continue color = _ADR_MARKET_COLORS[sym] if len(pts) >= 2: d_attr = ' L '.join(f'{x_for(date_to_i[d]):.1f} {y_for(v):.1f}' for d, v, _ in pts) line_parts.append(f'') last_d, last_v, last_is_live = pts[-1] lx, ly = x_for(date_to_i[last_d]), y_for(last_v) if last_is_live: marker_parts.append( f'' f'{last_v:.1f}*' ) else: marker_parts.append( f'' f'{last_v:.1f}' ) # 호버 인디케이터 — 세로선 1개 + 시장당 점 2개 (없는 시장은 JS가 hidden 처리). hover_g = ( f'' f'' f'' f'' f'' ) # 포인트 메타 — 날짜축 i 기준. 각 날짜에 두 시장 값(있는 만큼) 포함. # JS는 가장 가까운 x 찾고 양쪽 시장 점에 dot 배치 + 툴팁 두 줄 표시. pts_meta: list[dict] = [] for i, d in enumerate(all_dates): entry: dict = {'d': d, 'x': round(x_for(i), 2)} for sym in ('KOSPI', 'KOSDAQ'): found = next(((pv, plive) for pd, pv, plive in market_pts[sym] if pd == d), None) if found is not None: pv, plive = found key = 'kospi' if sym == 'KOSPI' else 'kosdaq' entry[key] = {'v': round(pv, 2), 'y': round(y_for(pv), 2), 'live': bool(plive)} pts_meta.append(entry) pts_json = html.escape(json.dumps(pts_meta, separators=(',', ':')), quote=True) # 툴팁 — 날짜 + 두 시장 값 (각각 색상). 시장 값 비어있으면 JS가 해당 줄 hide. tip_html = ( '' ) # 시장 색 범례 — 기간 표시는 차트 하단 x축으로 이동(축 라벨로 더 적합). legend_html = ( '
    ' f'코스피' f'코스닥' '
    ' ) # 차트 x축 하단 기간 라벨 — 시작/끝 날짜만 양 끝에 작게. first_dt, last_dt = all_dates[0], all_dates[-1] x_axis_y = H - 3 if first_dt == last_dt: x_axis_label = ( f'{html.escape(first_dt[5:])}' ) else: x_axis_label = ( f'{html.escape(first_dt[5:])}' f'{html.escape(last_dt[5:])}' ) sel_label = next((lbl for lbl, d in ADR_TREND_PRESETS if d == days), '맞춤') chart_row = ( f'
    ' f'' f'{guides}' f'{"".join(line_parts)}' f'{"".join(marker_parts)}' f'{x_axis_label}' f'{hover_g}' f'' f'{tip_html}' f'
    ' ) return ( '
    ' f'
    ADR 추세 {ADR_MA_WINDOW}일 이평 · ' + html.escape(sel_label) + '
    ' + summary_block + f'
    {ADR_MA_WINDOW}일 이동평균 ADR · 회색점선=100(균형) · 파란점선=75(침체) · 빨간점선=125(과열) · *=오늘(라이브 포함)
    ' + _render_adr_trend_controls(days) + legend_html + chart_row + '
    ' ) def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances: dict | None = None, journal_by_label: dict | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> str: """자산정보 탭 — sub-tab(자산보기/차트보기/시장정보)로 분리. - 자산보기(기본): owner별 compact KPI 카드 스택 (합산 / 각계좌별도 토글) - 차트보기: 기간·단위·모드 select + owner별 차트 (순자산 / 손익누적 토글) - 시장정보: 오늘의 ADR·투자자별 매매 카드 + ADR 추세 sparkline (기간 select, 라이브 포인트) 추가 키움 호출 없음 (_fetch_all_data가 모은 owner_data 재사용). 시장 카드는 네이버 m.stock 별도 fetch (5분 SWR).""" if not ordered_owners: return '
    계좌 데이터가 없습니다.
    ' balances = balances or {} journal_by_label = journal_by_label or {} consolidated_parts: list[str] = [] by_account_parts: list[str] = [] chart_parts: list[str] = [_render_chart_controls(chart_days, chart_unit, chart_mode)] for owner in ordered_owners: d = owner_data.get(owner) if not d: continue full_title = _owner_full_title(owner) consolidated_parts.append(_render_owner_kpi(owner, d, full_title, compact=True)) # 각계좌별도: owner의 labels 순서로 단일 계좌 카드 누적. prev_net 유무로 day_pl 근사 토글. has_prev_snap = d.get('prev_net') is not None owner_short = full_title.replace('님 자산', '님').replace(' 자산', '') owner_prefix = f'{owner}_' for lb in d.get('labels', []): label_disp = lb[len(owner_prefix):] if lb.startswith(owner_prefix) else lb by_account_parts.append(_render_account_kpi( owner, lb, d, balances, journal_by_label, owner_label_text=owner_short, has_prev_snap=has_prev_snap, label_disp=label_disp, )) chart_series, prev_baseline = _load_net_worth_series(owner, days=chart_days, unit=chart_unit, with_prev_baseline=True) if chart_series: # 스냅샷에 누적된 일자별 입출금 → unit 집계에 맞춰 sum. daily_cf = _load_cash_flow_by_date(owner) cf_by_date = _aggregate_cash_flows_by_unit(daily_cf, [d_ for d_, _ in chart_series], chart_unit) if daily_cf else {} # 오늘 live 포인트용 — snapshot에 아직 안 들어간 오늘 입출금. owner_data에 이미 합산되어 있음. today_cf = None if d.get('cash_in', 0) or d.get('cash_out', 0): today_cf = {'cash_in': d.get('cash_in', 0), 'cash_out': d.get('cash_out', 0)} if chart_mode in ('pnl', 'period'): # pnl·period 모두 '입출금 제거 누적손익' 시계열을 입력으로 받는다 (period는 _render에서 기간별 증분으로 그림). # series[0] 직전 영업일 net_worth 를 baseline 으로 → 첫날 손익도 표시. fallback baseline 케이스(이번주 휴장, 1월 1일 등)는 prev_baseline=None. prev_baseline_nw = prev_baseline[1] if prev_baseline else None pnl_series, baseline_nw, cum_cf = _to_pnl_series(chart_series, cf_by_date, prev_baseline_nw=prev_baseline_nw) # 차트 시각화에 baseline 점(예: 5/1) prepend → 첫 영업일 변동이 0 기점에서 분기되는 모양. baseline ↔ first 구간은 점선. prev_baseline_point = prev_baseline if prev_baseline else None # live current_pnl = current_net - baseline_nw - (시작점 이후 누적 cf + 오늘 cf). # series 마지막 점이 오늘이면 today_cf 는 baseline 이후 누적에 안 들어있으니 추가로 빼줘야 일관. current_net = d.get('total_net') if current_net is None: current_pnl = None else: today_net_cf = 0 if today_cf: today_net_cf = int(today_cf.get('cash_in', 0) or 0) - int(today_cf.get('cash_out', 0) or 0) current_pnl = int(current_net) - baseline_nw - cum_cf - today_net_cf chart_parts.append(_render_net_worth_chart( pnl_series, current_net=current_pnl, selected_days=chart_days, selected_unit=chart_unit, cash_flow_by_date=None, # pnl·period 모두 cf 영향 이미 제거 — 마커 숨김 today_cash_flow=None, mode=chart_mode, prev_baseline_point=prev_baseline, )) else: # net 모드도 baseline 점(예: 5/1) prepend → 첫 영업일 변동이 0 기점에서 분기되는 모양. baseline 구간은 점선. chart_parts.append(_render_net_worth_chart( chart_series, current_net=d.get('total_net'), selected_days=chart_days, selected_unit=chart_unit, cash_flow_by_date=cf_by_date, today_cash_flow=today_cf, mode='net', prev_baseline_point=prev_baseline, )) market_card = _render_market_indicators_card() adr_trend_card = _render_adr_trend_card(days=adr_days) # 자산보기 sub-panel 안 합산/각계좌별도 panes (양쪽 prerender). 토글 버튼은 페이지 상단(자동 토글 옆) 1곳으로 통일. assets_inner = ( '' '' ) 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 _render_interests_add_trigger() -> str: """패널 본문 하단에 표시되는 모달 오픈 트리거. 폼 자체는 shell HTML의 모달에 고정.""" return ( '
    ' '' '
    ' ) def _render_interests_edit_modal() -> str: """등록된 관심종목의 매수가/목표가/손절가/메모 수정 모달. shell HTML 직속에 둠. 삭제 버튼은 모달 내부에 별도 form(interest-delete-form 클래스)로 두고 HTML5 form attribute로 modal-actions의 submit 버튼에 연결 — 행 actions에서 빼냈다.""" return ( '' ) def _render_tag_modal() -> str: """종목 공용 태그 설정 모달. 어느 탭의 칩이든 +태그 버튼이든 동일하게 연다. 대장 버튼은 토글 — 클릭 즉시 set/clear & submit. 자유 입력은 텍스트칸 + 저장.""" return ( '' ) def _render_candidate_modal() -> str: """다중 매칭 시 사용자가 종목을 선택하는 별도 팝업. 추가 모달과 분리된 z-index 위층.""" return ( '' ) def _render_stock_name_modal() -> str: """거래내역 표에서 종목 셀 길게 누르면 풀네임 표시 — press-and-hold tooltip. 셀 위쪽으로 띄워 손가락에 가리지 않게. pointerdown=show / pointerup=hide.""" return ( '' ) def _render_trade_modal() -> str: """종목별 거래내역 팝업. shell HTML 직속이라 panels swap 영향 없음. trigger 클릭 → fetch → table 렌더.""" return ( '' ) def _render_info_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_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_interests_modal() -> str: """shell HTML 직속에 두는 종목 추가 모달. panels API의 swap 영역(section.tab-content) 밖이라 자동 갱신 중에도 DOM·입력값이 보존된다.""" return ( '' ) def _render_interests_panel(cards: list[dict]) -> tuple[str, int]: sections: list[str] = [] if not cards: sections.append('
    관심종목이 비어있습니다.
    ') else: show_day_change = _show_day_change_now() for c in cards: c['_show_day_change'] = show_day_change held = sorted([c for c in cards if c.get('mode') == 'held'], key=lambda c: c.get('held_qty', 0) * (c.get('held_avg_price') or 0), reverse=True) watching = sorted([c for c in cards if c.get('mode') != 'held'], key=lambda c: c.get('saved_at', ''), reverse=True) if held: sections.append(f'') sections.append('\n'.join(_render_row(c, source='interests') for c in held)) if watching: sections.append(f'') sections.append('\n'.join(_render_row(c, source='interests') for c in watching)) sections.append(_render_interests_add_trigger()) return '\n'.join(sections), len(cards) # /api/panels?owner= 쿼리 값 → 내부 owner 키. None이면 전체. TAB_KEY_TO_OWNER = {'self': '본인', 'gahee': '가희'} def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> dict: """fetch_all_data + 탭별 패널 HTML 생성. /api/panels와 캐시의 단위. owner 지정 시: 해당 owner 계좌만 호출하고 응답 tabs는 owner 탭 하나만 (summary·wl 제외). 클라이언트가 partial swap으로 사용 — 보지 않는 owner 호출 폭주 방지. 부수효과: fetched owner_data를 `_owner_data_cache`에 owner 단위로 적재해 이후 partial fetch가 'all' 캐시의 summary 슬라이스를 in-place 갱신할 때 재사용한다.""" wl_entries = _load_watchlist() in_entries = _load_interests() combined_entries = wl_entries + in_entries data = _fetch_all_data(combined_entries, only_owner=owner) all_cards = data['cards'] wl_cards = all_cards[:len(wl_entries)] in_cards = all_cards[len(wl_entries):] owner_data = data['owner_data'] ordered_owners = data['ordered_owners'] balances = data['balances'] journal_by_label = data.get('journal_by_label', {}) or {} _save_owner_slices(owner_data, balances, journal_by_label) now = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S') fetched_hms = now # 셸 초기 렌더의 "갱신 YYYY-MM-DD HH:MM:SS" 포맷과 통일. market_active = _market_active_now() show_day_change = _show_day_change_now() tabs: list[dict] = [] if owner is None: # 전체 — 모든 탭 채움. 모두 동일 fetched_at. wl_body, wl_count = _render_watchlist_panel(wl_cards) in_body, in_count = _render_interests_panel(in_cards) summary_body = _render_summary_panel(ordered_owners, owner_data, balances, journal_by_label, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days) tabs.append({'id': 'tab-summary', 'label': '자산정보', 'html': summary_body, 'fetched_at': fetched_hms}) for o in ordered_owners: d = owner_data[o] tab_id = _owner_tab_id(o) full_title = _owner_full_title(o) title_short = full_title.replace('님 자산', '').replace(' 자산', '') tab_label = title_short panel = _render_owner_panel(o, d, balances, full_title, show_day_change, chart_days=chart_days, chart_unit=chart_unit) tabs.append({'id': f'tab-{tab_id}', 'label': tab_label, 'html': panel, 'fetched_at': fetched_hms}) tabs.append({'id': 'tab-interests', 'label': f'관심종목 {in_count}', 'html': in_body, 'fetched_at': fetched_hms}) tabs.append({'id': 'tab-wl', 'label': f'감시종목 {wl_count}', 'html': wl_body, 'fetched_at': fetched_hms}) else: # owner 단일 — 그 owner 탭만 응답. 클라이언트 apply가 해당 탭만 swap. # 다른 탭들의 fetched_at은 클라이언트 측 tabFetched 누적 dict가 유지. d = owner_data.get(owner) if d: tab_id = _owner_tab_id(owner) full_title = _owner_full_title(owner) title_short = full_title.replace('님 자산', '').replace(' 자산', '') tab_label = title_short panel = _render_owner_panel(owner, d, balances, full_title, show_day_change, chart_days=chart_days, chart_unit=chart_unit) tabs.append({'id': f'tab-{tab_id}', 'label': tab_label, 'html': panel, 'fetched_at': fetched_hms}) indices = _fetch_indices() return { 'now': now, 'market_active': market_active, 'first_tab_id': 'tab-summary', 'owner': owner, 'tabs': tabs, 'indices': indices, 'ticker_items': indices, } def _shell_tab_descriptors() -> list[tuple[str, str]]: """데이터 fetch 없이 (tab_id, placeholder_label) 순서. 셸 응답이 사용 — owner 라벨은 금액 없이 short form.""" descs: list[tuple[str, str]] = [('tab-summary', '자산정보')] for owner, tid in OWNER_TAB_IDS.items(): full = _owner_full_title(owner) short = full.replace('님 자산', '').replace(' 자산', '') descs.append((f'tab-{tid}', short)) descs.append(('tab-interests', '관심종목')) descs.append(('tab-wl', '감시종목')) return descs def render_html() -> str: """첫 진입용 셸 HTML — 데이터 fetch 없음, 즉시 응답. 클라이언트 JS가 /api/panels를 fetch해 본문/라벨을 DOM swap. 자동 갱신·PTR·새로고침 버튼도 같은 fetch 경로로 통일 (location.reload 없음).""" descs = _shell_tab_descriptors() valid_tids = [tid for tid, _ in descs] first_tid = descs[0][0] market_active = _market_active_now() now = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S') nav_html = '\n'.join( f'{html.escape(label)}' for tid, label in descs ) spinner_placeholder = ( '
    ' '불러오는 중…
    ' ) content_html = '\n'.join( f'
    {spinner_placeholder}
    ' for tid in valid_tids ) show_rules = ',\n'.join( f'html[data-tab="{tid}"] section[data-tab="{tid}"]' for tid in valid_tids ) active_rules = ',\n'.join( f'html[data-tab="{tid}"] nav.tabs a[href="#{tid}"]' for tid in valid_tids ) dynamic_css = ( f'{show_rules},\n' f'html:not([data-tab]) section[data-tab="{first_tid}"] {{ display: block; }}\n' f'{active_rules},\n' f'html:not([data-tab]) nav.tabs a[href="#{first_tid}"] ' f'{{ color: #f0f0f0; border-bottom-color: #ff4d5e; }}\n' ) valid_tids_js = ','.join(f"'{tid}'" for tid in valid_tids) # 자동 갱신 동작 탭 — 자산정보·본인·가희(owner 부분 fetch)·관심종목(전체 fetch). 감시종목은 시세 변화 추적 대상 아니라 제외. auto_refresh_tids_js = ','.join(f"'{tid}'" for tid in valid_tids if tid != 'tab-wl') # 탭 활성 동기화 — URL hash → html[data-tab]. CSS-only 전환이라 클릭은 즉시. # 자산정보 탭으로 진입할 때는 전체 fetch 트리거 — 모든 owner를 한 화면에 모아 보는 요약 탭이라 stale 최소화. head_script = ( '' ) # 패널 fetch + DOM swap. # load() — 전체 (모든 탭 채움, 첫 진입·새로고침·PTR·자산정보 탭 진입) # load("self"|"gahee") — owner 탭 하나만 받아 partial swap (자동 갱신) # 응답 tabs는 받은 만큼만 swap하므로 보지 않는 owner의 stale 데이터는 그대로 둠. # inFlight를 key별로 분리해 owner 갱신과 전체 fetch가 충돌하지 않도록. panels_script = ( '' ) # 순자산 차트 인터랙션: 호버/터치 시 indicator 세로선 + 툴팁(그날 손익·누적 손익). # data-pts JSON 파싱 → pointermove로 최근접 포인트 찾아 indicator·tooltip 갱신. # MutationObserver로 swap 후 새 .net-chart 자동 재바인딩. netchart_script = ( '' ) # ADR 통합 차트 인터랙션 — 터치/호버 시 세로선 + 시장당 점 + 듀얼 툴팁(날짜·코스피·코스닥). # data-adr-pts JSON 각 entry: {d, x, kospi?:{v,y,live}, kosdaq?:{v,y,live}}. # 영속화: hide 타이머 없음 + module 스코프 selectedDate 에 마지막 선택 날짜 저장 → # 자동 갱신(panels innerHTML swap) 후 init 시 동일 날짜로 indicator 복원. adr_hover_script = ( '' ) # 보유 카드 SVG 봉차트 — 카드 펼침 시 /api/chart_svg fetch. # dailyCache: code -> 1Y/6M/1M HTML fragment. # minuteCache: code:unit -> {svg, ts} (TTL 30s — 오늘 마지막 봉이 분 단위로 흐름). # activeRange: code -> 마지막 활성 range. panels swap 후 1Y 로 reset 되는 거 방지. # panels-loaded 이벤트마다 열린 카드 daily 복원 + activeRange 적용. 활성이 분봉이면 자동 재fetch. chart_svg_script = ( '' ) # pull-to-refresh: 임계 넘으면 fetch(__behive_load) + spinner 유지, 완료 후 reset. reload() 안 함. # iOS PWA엔 네이티브 PTR 부재라 직접 구현. resistance 0.55로 손맛. ptr_script = ( '' ) # 자동 갱신 토글 — localStorage 영속, 계좌 탭 보일 때만 __behive_load 호출. # 사이클: off(0) → 10s(1) → 3s(2) → off. 기존 "1" 값은 10s 모드와 호환. # location.reload() 제거 → 깜빡임·탭 클릭 묻힘 없음. fetch 완료 후에 재무장(arm)해 호출 중첩 방지. market_active_js = 'true' if market_active else 'false' auto_reload_script = ( '' ) # 더블탭 zoom 차단 — iOS Safari가 `touch-action: manipulation`을 무시할 때 폴백. # 300ms 안에 두 번째 touchend가 들어오면 preventDefault → 시스템 zoom 동작·동시 발생하는 ghost click 둘 다 억제. # 첫 탭의 click은 정상 dispatch되므로 details 토글·새로고침 버튼 등 단일 탭 UX 영향 없음. nodbltap_script = ( '' ) idx_info_modal_html = ( '' ) interest_modal_html = _render_interests_modal() candidate_modal_html = _render_candidate_modal() interest_edit_modal_html = _render_interests_edit_modal() tag_modal_html = _render_tag_modal() trade_modal_html = _render_trade_modal() info_modal_html = _render_info_modal() info_desc_modal_html = _render_info_desc_modal() order_modal_html = _render_order_modal() pin_modal_html = _render_pin_modal() open_orders_modal_html = _render_open_orders_modal() stock_name_modal_html = _render_stock_name_modal() # 모달 열기/닫기 + 폼 fetch — 트리거 버튼은 panels swap으로 다시 그려지므로 이벤트 위임. # 모달 DOM 자체는 shell HTML 직속이라 swap 영향 받지 않음 → 입력값 보존. # /interests/add 응답은 Accept: application/json 시 JSON. 다중 매칭이면 후보 버튼 표시. modal_script = ( '' ) # 거래내역 종목 셀 press-and-hold tooltip — pointerdown=show, pointerup/cancel/scroll=hide. # 셀 상단에 띄워 손가락에 안 가리게. 화면 위 공간 부족하면 셀 하단으로 fallback. stock_name_tip_script = ( '' ) # details.row[data-row-key] 그룹 mutex — 새로 열리면 다른 열려있던 것 자동 닫음. # capture phase 사용: toggle 이벤트는 bubble 안 함. panels apply가 다시 그릴 때 setAttribute('open') # 호출이 다시 trigger되어도 한 개만 남는다. details_mutex_script = ( '' ) # 주문 모달 + PIN 모달 JS — 호가 1초 폴링, 매수/매도, 호가 클릭 → 단가, 계좌 select → /api/order/check. # propose 성공 시 별도 PIN 모달(pin-modal)로 swap. verify는 PIN 모달 안에서 처리. # window.openOrderModal({code, name, side?, account?, accounts?, price?}) — trigger 진입점. order_modal_script = r'''''' return f''' 자산현황 {head_script}

    자산현황

    갱신 {now}
    {content_html}
    지수 불러오는 중…
    {idx_info_modal_html} {interest_modal_html} {interest_edit_modal_html} {tag_modal_html} {candidate_modal_html} {trade_modal_html} {info_modal_html} {info_desc_modal_html} {order_modal_html} {pin_modal_html} {open_orders_modal_html} {stock_name_modal_html} {ptr_script} {panels_script} {netchart_script} {adr_hover_script} {chart_svg_script} {auto_reload_script} {nodbltap_script} {details_mutex_script} {modal_script} {order_modal_script} {stock_name_tip_script} ''' # ── 패널 페이로드 인메모리 캐시 ── # /api/panels GET마다 키움 호출 발생 → owner별 캐시로 사용자가 보는 탭만 갱신. # key=None('all'): 전체 — kt00018·kt00001·ka10170 × 4계좌 + ka10095 = ~13콜 # key='본인': 본인 2계좌 — kt00018·kt00001·ka10170 × 2 = ~6콜 (워치리스트 quotes skip) # key='가희': 가희 2계좌 — 동일 ~6콜 # 자동 갱신(10s) 주기에 맞춰 TTL=10s — 락이 동시 요청을 한 fetch로 모아 모바일 Safari timeout 흡수. # 셸 HTML은 데이터 fetch 없이 매번 새로 만들어도 무료라 캐시 안 함. 워치리스트 변경(POST) 시 모든 owner 캐시 즉시 invalidate. PANELS_CACHE_TTL = 10.0 _panels_cache: dict[str, dict] = {} # cache_key('all'|owner) → {'json': bytes, 'expires_at': float} _panels_locks: dict[str, threading.Lock] = {} _panels_locks_master = threading.Lock() # 데이터 레이어 캐시 — owner별 raw owner_data + balances 보존. # 자산정보 패널은 owner별 KPI 카드 스택이라 owner_data만으로 재렌더 가능. partial fetch가 # 자기 owner 슬라이스를 새로 채울 때 'all' 캐시의 summary 슬라이스와 해당 owner 탭만 in-place로 # 갈아끼워 자산정보-관리자 탭 간 KPI 불일치를 제거한다. TTL 별도 없음 — 'all'이 만료되면 # 어차피 full fetch로 동기화돼 누적 stale 위험 제한적. _owner_data_cache: dict[str, dict] = {} # owner → {'owner_data': dict, 'balances': dict, 'fetched_at': float} def _panels_lock_for(key: str) -> threading.Lock: with _panels_locks_master: lk = _panels_locks.get(key) if lk is None: lk = threading.Lock() _panels_locks[key] = lk return lk def _save_owner_slices(owner_data: dict, balances: dict, journal_by_label: dict | None = None) -> None: """fetched owner_data를 owner 단위로 분리해 _owner_data_cache에 적재. balances는 owner의 labels로 필터링 — 다른 owner 계좌 데이터가 섞이지 않도록. journal_by_label도 동일하게 labels로 필터링해 보관 (자산보기 각계좌별도 토글 KPI용).""" now_ts = time.time() journal_by_label = journal_by_label or {} for owner, d in owner_data.items(): owner_labels = d.get('labels') or [] owner_balances = {lb: balances[lb] for lb in owner_labels if lb in balances} owner_journal = {lb: journal_by_label[lb] for lb in owner_labels if lb in journal_by_label} _owner_data_cache[owner] = { 'owner_data': d, 'balances': owner_balances, 'journal_by_label': owner_journal, 'fetched_at': now_ts, } def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> None: """partial fetch 직후 'all' 캐시의 summary 패널과 해당 owner 패널만 in-place로 갈아끼운다. 다른 owner와 watchlist/interests 슬라이스는 cached 'all'에 있던 그대로 — TTL 만료 시 full fetch로 동기화. 핵심 목표: 자산정보 탭의 본인 KPI가 관리자 탭과 같은 시점이 되도록.""" all_key = f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}' all_lock = _panels_lock_for(all_key) with all_lock: slot = _panels_cache.get(all_key) if not slot or slot['expires_at'] <= time.time(): return # 캐시 없거나 만료 → 다음 자산정보 진입에서 어차피 full fetch try: payload = json.loads(slot['json'].decode('utf-8')) except Exception: traceback.print_exc() return merged = {o: e['owner_data'] for o, e in _owner_data_cache.items()} if not merged: return ordered = sorted(merged.keys(), key=lambda o: (o != '본인', o)) # 각계좌별도 토글 KPI에 필요 — slice된 balances·journal_by_label을 owner 단위로 다시 union. merged_balances: dict = {} merged_journal: dict = {} for e in _owner_data_cache.values(): merged_balances.update(e.get('balances') or {}) merged_journal.update(e.get('journal_by_label') or {}) new_summary = _render_summary_panel(ordered, merged, merged_balances, merged_journal, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days) entry = _owner_data_cache.get(owner) if not entry: return show_day_change = _show_day_change_now() full_title = _owner_full_title(owner) new_owner_panel = _render_owner_panel(owner, entry['owner_data'], entry['balances'], full_title, show_day_change, chart_days=chart_days, chart_unit=chart_unit) owner_tab_id = f'tab-{_owner_tab_id(owner)}' fetched_hms = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S') for tab in payload.get('tabs', []): tid = tab.get('id') if tid == 'tab-summary': tab['html'] = new_summary tab['fetched_at'] = fetched_hms elif tid == owner_tab_id: tab['html'] = new_owner_panel tab['fetched_at'] = fetched_hms payload['now'] = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S') payload['market_active'] = market_active new_body = json.dumps(payload, ensure_ascii=False).encode('utf-8') # TTL 유지 — patch는 fetch가 아니라 일부 슬라이스만 갱신했으니 원래 만료 시각 그대로. _panels_cache[all_key] = {'json': new_body, 'expires_at': slot['expires_at']} def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS) -> bytes: """fresh=True 시 캐시 우회 — 명시적 새로고침(?fresh=1)에서 호출. 응답을 새로 만든 뒤에는 캐시에 덮어쓰므로 그 후 자동 갱신은 새 캐시를 본다. chart_days × chart_unit × chart_mode × adr_days 별로 캐시 키 분리 — 옵션마다 다른 series라 같은 owner라도 별도 슬롯. owner partial fetch 직후엔 'all' 캐시의 summary 슬라이스와 해당 owner 탭을 in-place로 갈아끼운다(_patch_all_cache_for_owner). 자산정보 = 본인+가희 KPI 카드 스택이라 owner_data만 있으면 재렌더 가능 — 다시 fetch할 필요 없음. 패치 실패 시 안전망으로 'all' 캐시 비워 다음 자산정보 진입에서 full fetch.""" key = f'{owner or "all"}:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}' if not fresh: slot = _panels_cache.get(key) if slot and slot['expires_at'] > time.time(): return slot['json'] lock = _panels_lock_for(key) with lock: if not fresh: slot = _panels_cache.get(key) if slot and slot['expires_at'] > time.time(): return slot['json'] payload = _build_panels_payload(owner=owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days) body = json.dumps(payload, ensure_ascii=False).encode('utf-8') _panels_cache[key] = {'json': body, 'expires_at': time.time() + PANELS_CACHE_TTL} if owner is not None: try: _patch_all_cache_for_owner(owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days) except Exception: traceback.print_exc() _panels_cache.pop(f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}', None) return body def _invalidate_panels_cache() -> None: with _panels_locks_master: _panels_cache.clear() GZIP_MIN_BYTES = 1024 # 1KB 미만은 압축 오버헤드가 절감보다 큼 — 303 redirect·작은 ok 응답 등 skip def _maybe_gzip(body: bytes, accept_encoding: str | None) -> tuple[bytes, str | None]: """클라이언트가 gzip 수용하면 압축본 반환. compresslevel=6은 디폴트(속도/크기 균형).""" if not body or len(body) < GZIP_MIN_BYTES: return body, None ae = (accept_encoding or '').lower() if 'gzip' not in ae: return body, None try: return gzip.compress(body, compresslevel=6, mtime=0), 'gzip' except Exception: return body, None class Handler(BaseHTTPRequestHandler): server_version = 'BehiveWeb/1.0' def log_message(self, fmt, *args): # type: ignore[override] sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} {fmt % args}\n') sys.stdout.flush() def _send_json(self, code: int, payload: dict) -> None: body = json.dumps(payload, ensure_ascii=False).encode('utf-8') body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding')) self.send_response(code) self.send_header('Content-Type', 'application/json; charset=utf-8') self.send_header('Content-Length', str(len(body))) if enc: self.send_header('Content-Encoding', enc) self.send_header('Vary', 'Accept-Encoding') self.send_header('Cache-Control', 'no-store') self.end_headers() try: self.wfile.write(body) except (BrokenPipeError, ConnectionResetError): pass def do_GET(self): # noqa: N802 from urllib.parse import urlparse, parse_qs parsed = urlparse(self.path) if parsed.path == '/api/trades': qs = parse_qs(parsed.query) code = (qs.get('code') or [''])[0].strip() account = (qs.get('account') or [''])[0].strip() owner_q = (qs.get('owner') or [''])[0].strip() if not code and not owner_q: self._send_json(400, {'error': 'code or owner required'}) return try: import trade_journal as tj from stock_portfolio_report import OWNER_LABELS as _OWNER_LABELS def _row_owner(r: dict) -> str: return r.get('owner') or ('가희' if r.get('account', '').startswith('가희_') else '본인') all_rows = tj._load_all() if code: rows = [r for r in all_rows if r.get('code') == code] else: rows = [r for r in all_rows if _row_owner(r) == owner_q] if account: rows = [r for r in rows if r.get('account') == account] rows.sort(key=lambda r: (r.get('date', ''), r.get('account', '')), reverse=True) name = rows[0]['name'] if rows and code else '' mode = 'owner' if (not code and owner_q) else 'code' total_buy = sum(r.get('buy_amt', 0) for r in rows) total_sell = sum(r.get('sell_amt', 0) for r in rows) total_pl = sum(r.get('pl_amt', 0) for r in rows) total_fee = sum(r.get('cmsn_tax', 0) for r in rows) def _owner_short(o: str) -> str: full = _OWNER_LABELS.get(o, f'{o} 자산') return full.replace('님 자산', '').replace(' 자산', '') groups_map: dict[str, dict] = {} for r in rows: o = r.get('owner') or ('가희' if r.get('account', '').startswith('가희_') else '본인') g = groups_map.setdefault(o, { 'owner': o, 'label': _owner_short(o), 'rows': [], 'total_buy_amt': 0, 'total_sell_amt': 0, 'total_pl_amt': 0, 'total_cmsn_tax': 0, }) g['rows'].append(r) g['total_buy_amt'] += r.get('buy_amt', 0) g['total_sell_amt'] += r.get('sell_amt', 0) g['total_pl_amt'] += r.get('pl_amt', 0) g['total_cmsn_tax'] += r.get('cmsn_tax', 0) ordered_groups = sorted(groups_map.values(), key=lambda g: (g['owner'] != '본인', g['owner'])) except Exception: traceback.print_exc() self._send_json(500, {'error': 'trades load failed'}) return self._send_json(200, { 'code': code, 'name': name, 'owner': owner_q, 'mode': mode, 'account': account, 'rows': rows, 'groups': ordered_groups, 'total_buy_amt': total_buy, 'total_sell_amt': total_sell, 'total_pl_amt': total_pl, 'total_cmsn_tax': total_fee, }) return if parsed.path == '/api/symbols/all': # 거래 모달 상단 종목 변경 드롭다운용 — 보유+관심+감시 통합 (dedup by code) try: import kiwoom_client as kc seen: set = set() items: list = [] # 보유 (4계좌) try: pos_by_acc = kc.get_positions_all() for label, positions in (pos_by_acc or {}).items(): owner = '가희' if label.startswith('가희_') else '본인' for p in positions: code = p.get('code') if not code or code in seen: continue seen.add(code) items.append({'code': code, 'name': p.get('name', ''), 'source': '보유', 'owner': owner}) except Exception: traceback.print_exc() # 관심종목 try: for c in _load_interests(): code = c.get('code') if not code or code in seen: continue seen.add(code) items.append({'code': code, 'name': c.get('stock', ''), 'source': '관심', 'owner': ''}) except Exception: traceback.print_exc() # 감시종목 try: for c in _load_watchlist(): code = c.get('code') if not code or code in seen: continue seen.add(code) items.append({'code': code, 'name': c.get('stock', ''), 'source': '감시', 'owner': ''}) except Exception: traceback.print_exc() self._send_json(200, {'items': items, 'count': len(items)}) except Exception as e: traceback.print_exc() self._send_json(500, {'error': f'symbols failed: {e}'}) return if parsed.path == '/api/orders/open': # 4계좌 통합 미체결 주문 — open-orders-modal이 표시 try: import kiwoom_client as kc rows = kc.get_open_orders_all() self._send_json(200, {'rows': rows, 'count': len(rows)}) except Exception as e: traceback.print_exc() self._send_json(500, {'error': f'open orders failed: {e}'}) return if parsed.path == '/api/order/active': # 현재 활성 카드 (PinStore.peek) + 미체결 주문 정보 — 거래 모달 [📋 진행중] 탭이 표시 try: import time as _time from orders.pin import PinStore pending = PinStore().peek() unique_codes: set = set() result: dict = {'active': False} if pending is not None and not pending.is_expired() and not pending.consumed: payload = pending.payload or {} elapsed = _time.time() - pending.issued_at remaining = max(0, int(pending.expiry_seconds - elapsed)) side_label = '매수' if payload.get('side') == 'BUY' else '매도' price_val = payload.get('price') price_str = f"{price_val:,}원" if price_val else '시장가' card_message = ( f"💳 활성 카드 [{pending.card_id}]\n" f"종목: {payload.get('symbol_name','')} ({payload.get('symbol','')})\n" f"{side_label} {payload.get('qty',0):,}주 @ {price_str}\n" f"계좌: {payload.get('account','')}" ) result = { 'active': True, 'card_id': pending.card_id, 'symbol_name': payload.get('symbol_name', ''), 'symbol': payload.get('symbol', ''), 'card_message': card_message, 'expiry_seconds': remaining, } if payload.get('symbol'): unique_codes.add(payload['symbol']) # 4계좌 미체결 주문 ka10075 통합 open_orders_count = 0 try: import kiwoom_client as kc rows = kc.get_open_orders_all() open_orders_count = len(rows) for r in rows: if r.get('code'): unique_codes.add(r['code']) except Exception: traceback.print_exc() result['open_orders_count'] = open_orders_count result['unique_count'] = len(unique_codes) self._send_json(200, result) except Exception as e: traceback.print_exc() self._send_json(500, {'error': f'active card lookup failed: {e}'}) return if parsed.path == '/api/market_state': try: self._send_json(200, _market_phase_state()) except Exception as e: traceback.print_exc() self._send_json(500, {'error': f'market_state failed: {e}'}) return if parsed.path == '/api/quote_book': # 거래 모달 호가창용. ka10004(호가 10단계+잔량) + ka10001(현재가·등락) 묶음. # 1초 polling 가정 — visibility 가드는 클라이언트 측 책임. qs = parse_qs(parsed.query) code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum()) if not code: self._send_json(400, {'error': 'code required'}) return try: import kiwoom_client as kc book = kc.get_quote_book(code) quote = kc.get_stock_quote(code) book['price'] = quote.get('price', 0) book['change'] = quote.get('change', 0) book['change_pct'] = quote.get('change_pct', 0.0) book['name'] = quote.get('name', '') # NXT 거래 가능 여부 (ka10099 캐시 nxtEnable) — 캐시 없거나 모르면 보수적 True try: meta = kc.lookup_stock_meta(code) book['nxt_enable'] = True if (not meta) else bool(meta.get('nxt_enable', True)) except Exception: book['nxt_enable'] = True book.pop('raw', None) except Exception as e: traceback.print_exc() self._send_json(500, {'error': f'quote_book failed: {e}'}) return self._send_json(200, book) return if parsed.path == '/api/stock_info': # 기업정보 모달용. ka10001 기반 시총·PER·PBR·EPS·BPS·ROE·52주·외국인비율. qs = parse_qs(parsed.query) code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum()) if not code: self._send_json(400, {'error': 'code required'}) return try: import kiwoom_client as kc info = kc.get_stock_basic(code) info.pop('raw', None) # 분기 영업이익(네이버) — 확정 분기 중 최신. 실패해도 키움 데이터는 유지. q = _fetch_quarter_op_profit(code) info['op_profit_q'] = q['value'] if q else None info['op_profit_q_period'] = q['period'] if q else None # FnGuide 성장성·컨센서스 보강 (조회 실패·ETF면 None). try: import fnguide_client as fg fund = fg.get_fundamentals(code) info['fundamentals'] = { 'growth': fund.get('growth'), 'latest_period': fund.get('latest_period'), 'consensus': fund.get('consensus'), } if fund else None except Exception: info['fundamentals'] = None # WISEreport 컨센서스(컴팩트) — 리비전 방향·서프라이즈·올해 추정. try: import wisereport_client as wr cons = wr.get_consensus(code) if cons: rev = cons.get('revision') or {} sup = (cons.get('surprise') or {}).get('items') or {} est_row = next((a for a in (cons.get('annual') or []) if a.get('is_estimate')), None) info['consensus'] = { 'target_change_pct': rev.get('target_change_pct'), 'est_change_pct': rev.get('est_change_pct'), 'rev_item': rev.get('item_name'), 'rev_weeks': rev.get('n_points'), 'this_year': ({ 'period': est_row.get('period'), 'sales': est_row.get('sales'), 'op': est_row.get('op'), 'eps': est_row.get('eps'), 'roe': est_row.get('roe'), } if est_row else None), 'surprise_sales': (sup.get('매출액') or {}).get('fy0_surprise_pct'), 'surprise_op': (sup.get('영업이익') or {}).get('fy0_surprise_pct'), 'surprise_year': (sup.get('매출액') or {}).get('fy0_year'), } else: info['consensus'] = None except Exception: info['consensus'] = None # 최근 증권사 분석리포트 (메타+요약, PDF 제외). 없으면 None. try: import wisereport_client as wr2 info['reports'] = wr2.get_reports(code, limit=6) except Exception: info['reports'] = None except Exception as e: traceback.print_exc() self._send_json(500, {'error': f'stock_info failed: {e}'}) return self._send_json(200, info) return if parsed.path == '/api/stock_search': # 기업비교용 종목 검색. 단일 매칭이면 1건, 다중이면 후보 리스트 반환. qs = parse_qs(parsed.query) q = (qs.get('q') or [''])[0].strip() if not q: self._send_json(400, {'error': 'q required'}) return try: hit = _resolve_stock_fuzzy(q) self._send_json(200, {'results': [{'code': hit['code'], 'name': hit['name']}]}) except MultipleMatchError as e: self._send_json(200, {'results': [{'code': c['code'], 'name': c['name']} for c in e.candidates[:20]]}) except Exception as e: self._send_json(200, {'results': [], 'error': str(e)}) return if parsed.path == '/api/order/check': # 거래 모달용: 계좌·종목·side(BUY/SELL)·order_type·단가(선택) 받아서 # 최대 거래 가능 주수, 잔액·보유주수·평단가, 매도 시 손익 미리보기 반환. # MARKET BUY 는 키움 증거금 기준(상한가)로 max_qty 계산해 사전 차단. qs = parse_qs(parsed.query) code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum()) account = (qs.get('account') or [''])[0].strip() side = (qs.get('side') or [''])[0].strip().upper() order_type = (qs.get('order_type') or ['LIMIT'])[0].strip().upper() or 'LIMIT' if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'): order_type = 'LIMIT' try: price = int((qs.get('price') or ['0'])[0].strip() or '0') except ValueError: price = 0 if not code or not account or side not in ('BUY', 'SELL'): self._send_json(400, {'error': 'code/account required, side=BUY|SELL'}) return try: import kiwoom_client as kc result = {'code': code, 'account': account, 'side': side, 'order_type': order_type, 'price': price} if side == 'BUY': bal = kc.get_balance(account) ord_alow = int(bal.get('ord_alow_amt') or 0) result['ord_alow_amt'] = ord_alow result['d2_entra'] = int(bal.get('d2_entra') or 0) if order_type == 'MARKET': # 키움 시장가 매수 증거금 = 상한가 × qty. 같은 기준으로 max_qty 표시. try: q = kc.get_stock_quote(code, account_label=account, exchange='AL') raw = q.get('raw') if isinstance(q, dict) and isinstance(q.get('raw'), dict) else (q or {}) upper = abs(int(raw.get('upl_pric') or 0)) except Exception: upper = 0 result['upper_limit'] = upper result['max_qty'] = (ord_alow // upper) if upper > 0 else 0 else: # 키움 ord_alow_amt는 수수료 버퍼 이미 반영된 매수가능액. price>0 일 때만 max_qty. result['max_qty'] = (ord_alow // price) if price > 0 else 0 else: # SELL positions = kc.get_positions(account) pos = next((p for p in positions if p['code'] == code), None) if pos: result['hold_qty'] = pos['qty'] result['trde_able_qty'] = pos['trde_able_qty'] result['avg_price'] = pos['avg_price'] result['cur_price'] = pos['cur_price'] result['max_qty'] = pos['trde_able_qty'] # 선택 단가 기준 손익 미리보기 — 단가 0이면 현재가 fallback ref_price = price or pos['cur_price'] result['pl_preview'] = (ref_price - pos['avg_price']) * result['max_qty'] else: result.update({'hold_qty': 0, 'trde_able_qty': 0, 'avg_price': 0, 'cur_price': 0, 'max_qty': 0, 'pl_preview': 0}) except Exception as e: traceback.print_exc() self._send_json(500, {'error': f'order/check failed: {e}'}) return self._send_json(200, result) return if parsed.path == '/api/chart_svg': qs = parse_qs(parsed.query) code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum()) unit = (qs.get('unit') or [''])[0].strip() if not code: self._send_json(400, {'error': 'code required'}) return # 분봉 분기 — 캐시 미사용 (장중 매 호출마다 ka10080 한 콜). 응답은 SVG 단일. if unit in ('1m', '5m', '15m'): tic_scope = {'1m': 1, '5m': 5, '15m': 15}[unit] # range 파라미터 — 30min/1h/3h/5h/1d. 분 단위로 변환. 1d=None(오늘 전체). range_str = (qs.get('range') or ['1d'])[0].strip() range_minutes_map = {'30min': 30, '1h': 60, '3h': 180, '5h': 300, '1d': None} if range_str not in range_minutes_map: range_str = '1d' range_minutes = range_minutes_map[range_str] try: import stock_analysis as sa import kiwoom_client as kc mins = kc.get_minute_candles(code, tic_scope=tic_scope, today_only=True) if not mins: self.send_error(404, 'no minute candles') return # range 슬라이스 — 최근 N 봉만. if range_minutes is not None: num_bars = max(1, range_minutes // tic_scope) mins = mins[-num_bars:] # render_svg_chart 호환 — date 필드에 HH:MM 박아 X축 라벨로 사용. formatted = [] for m in mins: t = m.get('time', '') hhmm = f'{t[:2]}:{t[2:4]}' if len(t) >= 4 else t formatted.append({**m, 'date': hhmm, 'value': 0, 'turnover_rate': None}) closes = [c['close'] for c in formatted] snap = { 'candles_asc': formatted, 'sma5': sa.sma(closes, 5), 'sma20': sa.sma(closes, 20), 'sma60': sa.sma(closes, 60), } svg = sa.render_svg_chart(snap, range_key='1Y') body = svg.encode('utf-8') body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding')) self.send_response(200) self.send_header('Content-Type', 'image/svg+xml; charset=utf-8') self.send_header('Content-Length', str(len(body))) if enc: self.send_header('Content-Encoding', enc) self.send_header('Vary', 'Accept-Encoding') self.send_header('Cache-Control', 'no-store') self.end_headers() try: self.wfile.write(body) except (BrokenPipeError, ConnectionResetError): pass except Exception: traceback.print_exc() self.send_error(500, 'minute chart render failed') return try: import daily_candles_cache as dcc import stock_analysis as sa import kiwoom_client as kc candles_asc = dcc.get_candles(code, count=250) today = datetime.now(KST).strftime('%Y%m%d') try: qmap = kc.get_watchlist_quotes([code], exchange='AL') tq = qmap.get(code) if isinstance(qmap, dict) else None except Exception: tq = None live_today_ok = bool( tq and tq.get('price') and tq.get('open') and tq.get('high') and tq.get('low') ) if (live_today_ok and (not candles_asc or candles_asc[-1]['date'] != today)): candles_asc = candles_asc + [{ 'date': today, 'open': tq['open'], 'high': tq['high'], 'low': tq['low'], 'close': tq['price'], 'volume': tq.get('volume', 0), 'value': 0, 'turnover_rate': None, }] if not candles_asc: self.send_error(404, 'no candles') return closes = [c['close'] for c in candles_asc] snap = { 'candles_asc': candles_asc, 'sma5': sa.sma(closes, 5), 'sma20': sa.sma(closes, 20), 'sma60': sa.sma(closes, 60), } # 분석 페이지와 동일 패턴 — 1Y/6M/1M 3 개 panel + 탭. 250 봉 캐시 슬라이스만 다름. # 분봉 panel 은 빈 placeholder. 탭 클릭 시 클라이언트가 lazy fetch. svg_1y = sa.render_svg_chart(snap, range_key='1Y') svg_6m = sa.render_svg_chart(snap, range_key='6M') svg_1m = sa.render_svg_chart(snap, range_key='1M') # 분봉 panel 안에 기간 sub-tabs (30분/1h/3h/5h/하루). default = '1h'. # subpanel 은 클라이언트가 sub-toggle 이벤트로 lazy fetch. def _minute_panel(unit_id: str) -> str: return ( f'
    ' '
    ' '' '' '' '' '' '
    ' '
    ' '
    ' ) html_block = ( '
    ' '' '' '' '' '' '
    ' f'
    {svg_1y}
    ' f'
    {svg_6m}
    ' f'
    {svg_1m}
    ' + _minute_panel('5m') + _minute_panel('1m') ) body = html_block.encode('utf-8') body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding')) self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.send_header('Content-Length', str(len(body))) if enc: self.send_header('Content-Encoding', enc) self.send_header('Vary', 'Accept-Encoding') self.send_header('Cache-Control', 'no-store') self.end_headers() try: self.wfile.write(body) except (BrokenPipeError, ConnectionResetError): pass except Exception: traceback.print_exc() self.send_error(500, 'chart render failed') return if parsed.path == '/api/idx-chart': qs = parse_qs(parsed.query) sym = (qs.get('symbol') or [''])[0] if sym not in _IDX_CHART_SYMBOL.values(): self.send_error(400, 'bad symbol') return series = _fetch_idx_chart_series(sym) if not series: self.send_error(502, 'fetch failed') return body = json.dumps({'symbol': sym, 'series': series}, ensure_ascii=False).encode('utf-8') body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding')) self.send_response(200) self.send_header('Content-Type', 'application/json; charset=utf-8') self.send_header('Content-Length', str(len(body))) if enc: self.send_header('Content-Encoding', enc) self.send_header('Vary', 'Accept-Encoding') self.send_header('Cache-Control', 'max-age=60') self.end_headers() try: self.wfile.write(body) except (BrokenPipeError, ConnectionResetError): pass return if parsed.path == '/api/panels': qs = parse_qs(parsed.query) tab_key = (qs.get('owner') or [None])[0] fresh = (qs.get('fresh') or ['0'])[0] == '1' # 명시적 새로고침(fresh=1)일 땐 시장 지수 캐시도 무효화 — 시장 카드 새로고침 버튼이 fresh=1 사용. if fresh: with _indices_lock: _indices_cache['expires_at'] = 0 with _market_indicators_lock: _market_indicators_cache['expires_at'] = 0 with _extra_market_quotes_lock: _extra_market_quotes_cache['expires_at'] = 0 owner = TAB_KEY_TO_OWNER.get(tab_key) if tab_key else None try: chart_days = int((qs.get('chart_days') or [str(NET_WORTH_CHART_DAYS)])[0]) except (TypeError, ValueError): chart_days = NET_WORTH_CHART_DAYS chart_unit = (qs.get('chart_unit') or [NET_WORTH_CHART_UNIT])[0] if chart_unit not in {u for _, u in NET_WORTH_UNIT_PRESETS}: chart_unit = NET_WORTH_CHART_UNIT # 기간×단위 비허용 조합은 첫 허용 단위로 강제 (stale localStorage 방어) _allowed = NET_WORTH_ALLOWED_UNITS_BY_DAYS.get(chart_days) if _allowed and chart_unit not in _allowed: chart_unit = _allowed[0] chart_mode = (qs.get('chart_mode') or [NET_WORTH_CHART_MODE])[0] if chart_mode not in {m for _, m in NET_WORTH_MODE_PRESETS}: chart_mode = NET_WORTH_CHART_MODE try: adr_days = int((qs.get('adr_days') or [str(ADR_TREND_DAYS)])[0]) except (TypeError, ValueError): adr_days = ADR_TREND_DAYS if adr_days not in {d for _, d in ADR_TREND_PRESETS}: adr_days = ADR_TREND_DAYS try: body = _get_cached_panels_json(owner=owner, fresh=fresh, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days) except Exception: traceback.print_exc() self.send_error(500, 'panels failed') return body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding')) self.send_response(200) self.send_header('Content-Type', 'application/json; charset=utf-8') self.send_header('Content-Length', str(len(body))) if enc: self.send_header('Content-Encoding', enc) self.send_header('Vary', 'Accept-Encoding') self.send_header('Cache-Control', 'no-store') self.send_header('Referrer-Policy', 'no-referrer') self.end_headers() try: self.wfile.write(body) except (BrokenPipeError, ConnectionResetError): pass return # /stock/ — 종목 분석 상세 페이지 if parsed.path.startswith('/stock/'): tail = parsed.path[len('/stock/'):].strip('/') if not tail: self.send_error(404) return code = ''.join(ch for ch in tail.split('/')[0] if ch.isalnum()) if not code: self.send_error(400, 'bad stock code') return qs = parse_qs(parsed.query) selected = (qs.get('r') or [None])[0] err_msg = (qs.get('err') or [None])[0] try: import stock_analysis as sa page_html = sa.render_stock_page(code, selected_filename=selected, error_msg=err_msg) except Exception: traceback.print_exc() self.send_error(500, 'analysis page failed') return body = page_html.encode('utf-8') body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding')) self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.send_header('Content-Length', str(len(body))) if enc: self.send_header('Content-Encoding', enc) self.send_header('Vary', 'Accept-Encoding') self.send_header('Cache-Control', 'no-store') self.send_header('Referrer-Policy', 'no-referrer') self.end_headers() try: self.wfile.write(body) except (BrokenPipeError, ConnectionResetError): pass return if parsed.path not in ('/', '/index.html'): self.send_error(404) return try: # 셸 HTML은 데이터 fetch 없이 즉시 응답 — 키움 호출은 /api/panels에서. html_body = render_html() except Exception: traceback.print_exc() self.send_error(500, 'render failed') return body = html_body.encode('utf-8') body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding')) self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.send_header('Content-Length', str(len(body))) if enc: self.send_header('Content-Encoding', enc) self.send_header('Vary', 'Accept-Encoding') self.send_header('Cache-Control', 'no-store') self.send_header('Referrer-Policy', 'no-referrer') self.end_headers() try: self.wfile.write(body) except (BrokenPipeError, ConnectionResetError): pass def do_HEAD(self): # noqa: N802 # PWA navigation preload·link preview·NAS reverse proxy health check 등이 HEAD를 보낸다. # 키움 호출 트리거 없이 200만 돌려준다. if self.path not in ('/', '/index.html'): self.send_error(404) return self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.send_header('Cache-Control', 'no-store') self.send_header('Referrer-Policy', 'no-referrer') self.end_headers() def do_POST(self): # noqa: N802 wl_actions = {'/delete': 'delete', '/restore': 'restore', '/purge': 'purge'} in_actions = {'/interests/add': 'add', '/interests/update': 'update', '/interests/delete': 'delete'} length = int(self.headers.get('Content-Length', 0)) body = self.rfile.read(length).decode('utf-8') if length else '' from urllib.parse import parse_qs # keep_blank_values=True: edit modal의 "비우면 제거" UX가 동작하려면 빈 값도 payload에 살아있어야 한다. params = parse_qs(body, keep_blank_values=True) stock = (params.get('stock') or [''])[0].strip() wants_json = 'application/json' in (self.headers.get('Accept') or '') if self.path == '/api/order/propose': # 거래 모달 1단계: 주문 정보 → handler.propose_and_send → 카드 발급, PIN은 텔레그램. account = (params.get('account') or [''])[0].strip() side = (params.get('side') or [''])[0].strip().upper() symbol = ''.join(ch for ch in (params.get('symbol') or [''])[0].strip() if ch.isalnum()) symbol_name = (params.get('symbol_name') or [''])[0].strip() order_type = (params.get('order_type') or ['LIMIT'])[0].strip().upper() try: qty = int((params.get('qty') or ['0'])[0].strip() or '0') except ValueError: qty = 0 price_raw = (params.get('price') or [''])[0].strip() try: price = int(price_raw) if price_raw else None except ValueError: price = None if not account or side not in ('BUY', 'SELL') or not symbol or qty <= 0: self._send_json(400, {'ok': False, 'error': 'account/side/symbol/qty required'}) return if order_type not in ('LIMIT', 'MARKET'): self._send_json(400, {'ok': False, 'error': 'order_type must be LIMIT|MARKET'}) return if order_type == 'LIMIT' and (price is None or price <= 0): self._send_json(400, {'ok': False, 'error': 'LIMIT requires positive price'}) return try: if not symbol_name: import kiwoom_client as kc try: meta = kc.resolve_stock_code(symbol) symbol_name = meta.get('name') or symbol except Exception: symbol_name = symbol from orders import handler # propose_trade 직접 호출 — 거부 메시지는 web 토스트로만 (텔레그램 중복 발송 X). # 성공 시에만 카드+PIN을 텔레그램으로 발송 (사용자가 PIN 받아야 함). res = handler.propose_trade( account=account, side=side, symbol=symbol, symbol_name=symbol_name, qty=qty, order_type=order_type, price=(price if order_type == 'LIMIT' else None), ) if res.get('ok'): # 매수/매도 미리보기 카드 메시지·PIN 메시지 모두 텔레그램 발송 X. # 텔레그램은 매매등록(submit_with_pin)·매매체결(fill_watcher) 시점만 발송. # PIN은 iMessage로 iOS 자동입력 지원. try: from orders.pin import PinStore as _PinStore _pending = _PinStore().peek() if _pending and not _pending.consumed: side_label = '매수' if side == 'BUY' else '매도' handler.send_imessage_pin( pin=_pending.pin, card_id=_pending.card_id, side_label=side_label, symbol_name=symbol_name, ) except Exception: traceback.print_exc() except Exception as e: traceback.print_exc() self._send_json(500, {'ok': False, 'error': f'propose failed: {e}'}) return status = 200 if res.get('ok') else 400 self._send_json(status, res) return if self.path == '/api/order/verify': # 거래 모달 2단계: PIN 검증 → 키움 실주문 (dry_run=False). pin = (params.get('pin') or [''])[0].strip() if not pin: self._send_json(400, {'ok': False, 'error': 'pin required'}) return try: from orders import handler # submit_with_pin 직접 호출 — 거부 시 텔레그램 중복 발송 X. # 성공·체결 시에만 텔레그램 결과 발송. res = handler.submit_with_pin(pin, dry_run=False) if res.get('ok') and res.get('message'): try: handler.send_telegram(res['message'], parse_mode='Markdown') except Exception: traceback.print_exc() except Exception as e: traceback.print_exc() self._send_json(500, {'ok': False, 'error': f'verify failed: {e}'}) return status = 200 if res.get('ok') else 400 self._send_json(status, res) return if self.path == '/api/orders/cancel': # 미체결 주문 취소 (kt10003). ord_no + account 필수. ord_no = (params.get('ord_no') or [''])[0].strip() account = (params.get('account') or [''])[0].strip() if not ord_no or not account: self._send_json(400, {'ok': False, 'error': 'ord_no/account required'}) return try: from orders import handler res = handler.cancel_open_order(ord_no=ord_no, account=account) # 매매체결(취소) 알림은 텔레그램으로 (handler.cancel_open_order는 send 안 함) if res.get('ok') and res.get('message'): try: handler.send_telegram(res['message'], parse_mode=None) except Exception: traceback.print_exc() except Exception as e: traceback.print_exc() self._send_json(500, {'ok': False, 'error': f'open order cancel failed: {e}'}) return self._send_json(200 if res.get('ok') else 400, res) return if self.path == '/api/order/cancel': # 거래 모달 닫기 시 활성 카드 정리 (자동). 텔레그램 알림 X — 웹 자동 cancel은 사용자가 명시 액션 아님. try: from orders import handler res = handler.cancel_active_card() # cancel_active_card 반환은 PendingCard | None. dict로 변환. if res is None: self._send_json(200, {'ok': True, 'message': '활성 카드 없음'}) return self._send_json(200, {'ok': True, 'message': '카드 취소됨', 'card_id': res.card_id}) except Exception as e: traceback.print_exc() self._send_json(500, {'ok': False, 'error': f'cancel failed: {e}'}) return return if self.path in wl_actions: action = wl_actions[self.path] if not stock: self.send_error(400, 'Bad Request', explain='종목명 누락') return try: _apply_watchlist_action(action, stock) _invalidate_panels_cache() except KeyError: self.send_error(404, 'Not Found', explain=f'감시종목에 없음: {stock}') return except Exception as e: traceback.print_exc() self.send_error(500, 'Internal Error', explain=str(e)) return sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} stock={stock} → ok\n') sys.stdout.flush() self.send_response(303) self.send_header('Location', '/#tab-wl') self.end_headers() return if self.path == '/alerts/reset': if not stock: self.send_error(400, 'Bad Request', explain='종목명 누락') return try: _apply_alerts_reset(stock) except Exception as e: traceback.print_exc() self.send_error(500, 'Internal Error', explain=str(e)) return sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} stock={stock} → ok\n') sys.stdout.flush() self.send_response(303) self.send_header('Location', '/#tab-wl') self.end_headers() return if self.path in in_actions: action = in_actions[self.path] # add는 stock 또는 code 둘 중 하나면 OK — _apply_interests_action 안에서 검증. if not stock and action != 'add': if wants_json: self._send_json(400, {'ok': False, 'error': '종목명 누락'}) else: self.send_error(400, 'Bad Request', explain='종목명 누락') return payload = {k: (v[0] if v else '') for k, v in params.items() if k != 'stock'} try: _apply_interests_action(action, stock, payload) _invalidate_panels_cache() except MultipleMatchError as e: cands = [{'name': c['name'], 'code': c['code']} for c in e.candidates] if wants_json: self._send_json(200, {'ok': False, 'error': 'multiple', 'query': e.query, 'candidates': cands}) else: names = ', '.join(c['name'] for c in e.candidates[:5]) self.send_error(409, 'Conflict', explain=f'"{e.query}"와 일치하는 종목이 여러 개입니다. 후보: {names}') return except KeyError: msg = f'관심종목에 없음: {stock}' if wants_json: self._send_json(404, {'ok': False, 'error': msg}) else: self.send_error(404, 'Not Found', explain=msg) return except ValueError as e: if wants_json: self._send_json(400, {'ok': False, 'error': str(e)}) else: self.send_error(400, 'Bad Request', explain=str(e)) return except Exception as e: traceback.print_exc() if wants_json: self._send_json(500, {'ok': False, 'error': str(e)}) else: self.send_error(500, 'Internal Error', explain=str(e)) return sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} stock={stock} → ok\n') sys.stdout.flush() if wants_json: self._send_json(200, {'ok': True}) else: self.send_response(303) self.send_header('Location', '/#tab-interests') self.end_headers() return if self.path == '/tags/set': # 종목 공용 태그 — stock(name) 또는 code 둘 중 하나는 필수. # tag(text)+leader 모두 비어/false면 항목 제거. code = (params.get('code') or [''])[0].strip() tag_raw = (params.get('tag') or [''])[0] leader_raw = (params.get('leader') or ['0'])[0].strip().lower() leader = leader_raw in ('1', 'true', 'on', 'yes') if not stock and not code: if wants_json: self._send_json(400, {'ok': False, 'error': '종목 식별자 없음 (stock 또는 code 필요)'}) else: self.send_error(400, 'Bad Request', explain='종목 식별자 없음') return try: _set_stock_tag(code or None, stock or None, tag_raw, leader) _invalidate_panels_cache() except ValueError as e: if wants_json: self._send_json(400, {'ok': False, 'error': str(e)}) else: self.send_error(400, 'Bad Request', explain=str(e)) return except Exception as e: traceback.print_exc() if wants_json: self._send_json(500, {'ok': False, 'error': str(e)}) else: self.send_error(500, 'Internal Error', explain=str(e)) return sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} stock={stock} code={code} tag={tag_raw!r} leader={leader} → ok\n') sys.stdout.flush() if wants_json: self._send_json(200, {'ok': True}) else: self.send_response(303) self.send_header('Location', '/') self.end_headers() return # /stock//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) def cmd_serve() -> int: server = ThreadingHTTPServer((BIND_HOST, BIND_PORT), Handler) print(f'serving on http://{BIND_HOST}:{BIND_PORT}', flush=True) try: server.serve_forever() except KeyboardInterrupt: pass return 0 def cmd_render(out: str | None) -> int: body = render_html() if out: Path(out).write_text(body) print(f'wrote {out} ({len(body)} bytes)') else: sys.stdout.write(body) return 0 def main(argv: list[str]) -> int: p = argparse.ArgumentParser(prog='behive_web') sub = p.add_subparsers(dest='cmd', required=True) sub.add_parser('serve') pr = sub.add_parser('render') pr.add_argument('--out') args = p.parse_args(argv) if args.cmd == 'serve': return cmd_serve() if args.cmd == 'render': return cmd_render(args.out) return 2 if __name__ == '__main__': sys.exit(main(sys.argv[1:]))