#!/usr/bin/env python3 """종목분석 보고서 — 데이터 수집·SMA·SVG 차트·LLM 코멘트·저장소·큐 워커. behive_web에서 /stock/ 라우트가 import해서 호출. 보고서는 종목별 폴더에 Markdown으로 저장. 저장 구조: state/stock_reports/ 005930/ 2026-05-19_15-42.md 2026-05-19_16-10.md queue.json # {"status": "queued|running|done|failed", ...} 큐는 종목당 1개씩만 running. 동시 요청은 queued 상태로 안내 후 새로고침 시 결과 확인. """ from __future__ import annotations import json import os import sys import subprocess import threading import time from datetime import datetime from pathlib import Path try: from zoneinfo import ZoneInfo _KST = ZoneInfo('Asia/Seoul') except Exception: _KST = None # kiwoom_client 같은 디렉터리 _SCRIPT_DIR = Path(__file__).resolve().parent if str(_SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPT_DIR)) import kiwoom_client as kc # noqa: E402 import fnguide_client as fg # noqa: E402 import wisereport_client as wr # noqa: E402 WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace') STATE_DIR = WORKSPACE / 'state' REPORTS_DIR = STATE_DIR / 'stock_reports' REPORTS_DIR.mkdir(parents=True, exist_ok=True) PROMPT_FILE = WORKSPACE / 'prompts' / 'stock_analysis.md' ALIAS_FILE = WORKSPACE / 'prompts' / 'name_aliases.json' LLM_MODEL = 'openai-codex/gpt-5.5' LLM_TIMEOUT_SEC = 120 # ============================================================================ # 시간 헬퍼 # ============================================================================ def now_kst() -> datetime: if _KST: return datetime.now(_KST) return datetime.now() def ts_for_filename() -> str: return now_kst().strftime('%Y-%m-%d_%H-%M-%S') def ts_for_display() -> str: return now_kst().strftime('%Y-%m-%d %H:%M') # ============================================================================ # SMA 계산 (순수 파이썬, 외부 의존성 0) # ============================================================================ def sma(series: list[float], window: int) -> list[float | None]: """단순이동평균. series는 시간 오름차순 가정. 결과는 같은 길이, 초기 window-1개는 None. series 짧으면 window 채우기 전까지 None. 0 나누기·NaN 없이 안전. """ out: list[float | None] = [] s = 0.0 for i, v in enumerate(series): s += v if i >= window: s -= series[i - window] if i + 1 >= window: out.append(s / window) else: out.append(None) return out # ============================================================================ # 데이터 수집 — 키움 3 TR 합쳐서 보고서 입력 dict 만들기 # ============================================================================ def collect_snapshot(code: str, holding: dict | None = None) -> dict: """보고서 1회분 데이터 스냅샷. kiwoom_client 3 함수 호출 + SMA 계산. holding: 보유종목이면 {'avg_price', 'qty', 'eval_amount', 'profit_pct'}. 없으면 None. 반환 dict: basic: ka10001 정리 (현재가·시고저·시총·52주·PER/PBR/ROE 등) candles_asc: 시간 오름차순 일봉 (차트·SMA용) sma5, sma20, sma60: 각 길이 = len(candles_asc) flow: 외국인/기관 일별 매매 (최신순 20일) flow_summary: 외/기 20일 합산 + 매수일수 통계 holding: 보유 정보 (있을 때) snapshot_at: ISO 시각 """ basic = kc.get_stock_basic(code) candles_desc = kc.get_daily_candles(code, count=250) # 최신순 candles_asc = list(reversed(candles_desc)) # 시간순(차트 그리기용) closes = [c['close'] for c in candles_asc] sma5 = sma(closes, 5) sma20 = sma(closes, 20) sma60 = sma(closes, 60) flow = kc.get_investor_flow(code, days=20) flow_summary = _summarize_flow(flow) peers_data = _collect_peers(basic['code']) # FnGuide 펀더멘털 (매출/EPS 시계열·증가율·컨센서스). 조회 실패·ETF면 None. fundamentals = fg.get_fundamentals(basic['code']) # WISEreport 컨센서스 (연도별 추정·목표가 리비전·서프라이즈). 실패·ETF면 None. consensus = wr.get_consensus(basic['code']) return { 'code': basic['code'], 'name': basic['name'], 'snapshot_at': now_kst().isoformat(), 'basic': basic, 'candles_asc': candles_asc, 'sma5': sma5, 'sma20': sma20, 'sma60': sma60, 'flow': flow, 'flow_summary': flow_summary, 'holding': holding, 'peers': peers_data, 'fundamentals': fundamentals, 'consensus': consensus, } def _collect_peers(code: str) -> list[dict]: """등록된 peer 종목들의 기본 정보 수집. 실패한 종목은 skip하고 계속. 각 peer dict: {code, name, price, change, change_pct, per, pbr, roe, error?} """ peers = read_peers(code) if not peers: return [] out: list[dict] = [] for p in peers: pc = p['code'] try: b = kc.get_stock_basic(pc) out.append({ 'code': pc, 'name': b['name'] or p['name'], 'price': b['price'], 'change': b['change'], 'change_pct': b['change_pct'], 'open': b['open'], 'high': b['high'], 'low': b['low'], 'market_cap': b['market_cap'], 'listed_shares': b['listed_shares'], 'high_52w': b['high_52w'], 'low_52w': b['low_52w'], 'high_52w_dt': b['high_52w_dt'], 'low_52w_dt': b['low_52w_dt'], 'turnover_rate': b['turnover_rate'], 'per': b['per'], 'pbr': b['pbr'], 'roe': b['roe'], 'eps': b['eps'], 'bps': b['bps'], 'foreign_holding_pct': b['foreign_holding_pct'], 'error': None, }) except Exception as e: out.append({ 'code': pc, 'name': p['name'], 'error': str(e)[:120], }) return out def _summarize_flow(flow: list[dict]) -> dict: """20일치 수급 합산·매수일수. flow는 최신순. 단위는 천 주.""" if not flow: return {'days': 0, 'foreign_net': 0, 'institution_net': 0, 'individual_net': 0, 'foreign_buy_days': 0, 'institution_buy_days': 0} f_net = sum(r['foreign'] for r in flow) i_net = sum(r['institution'] for r in flow) p_net = sum(r['individual'] for r in flow) f_buy = sum(1 for r in flow if r['foreign'] > 0) i_buy = sum(1 for r in flow if r['institution'] > 0) return { 'days': len(flow), 'foreign_net': f_net, # 천 주 'institution_net': i_net, 'individual_net': p_net, 'foreign_buy_days': f_buy, 'institution_buy_days': i_buy, } # ============================================================================ # 저장소 — 보고서 목록·읽기·쓰기·큐 상태 # ============================================================================ def _code_dir(code: str) -> Path: code = ''.join(ch for ch in str(code) if ch.isalnum()) d = REPORTS_DIR / code d.mkdir(parents=True, exist_ok=True) return d def _queue_path(code: str) -> Path: return _code_dir(code) / 'queue.json' def read_queue(code: str) -> dict: p = _queue_path(code) if not p.exists(): return {'status': 'idle'} try: return json.loads(p.read_text()) except Exception: return {'status': 'idle'} def write_queue(code: str, state: dict) -> None: p = _queue_path(code) state = dict(state) state.setdefault('updated_at', now_kst().isoformat()) p.write_text(json.dumps(state, ensure_ascii=False, indent=2)) def list_reports(code: str) -> list[dict]: """보고서 목록 (최신순). 각 항목: {filename, ts, oneline, pct, label}. 첫 줄 주석에서 메타 파싱: / """ d = _code_dir(code) items = [] for p in sorted(d.glob('*.html'), reverse=True): meta = _parse_report_meta(p) items.append({ 'filename': p.name, 'ts': p.stem, 'path': str(p), 'oneline': meta.get('oneline', ''), 'pct': meta.get('pct', None), 'label': meta.get('label', ''), }) return items def _parse_report_meta(p: Path) -> dict: """파일 첫 6줄에서 주석 파싱.""" out: dict = {} try: head = p.read_text().splitlines()[:6] except Exception: return out for line in head: line = line.strip() if not (line.startswith('')): continue inner = line[4:-3].strip() if ':' not in inner: continue k, v = inner.split(':', 1) k = k.strip().lower() v = v.strip() if k == 'oneline': out['oneline'] = v elif k == 'recommendation': # 형식: "45% / 중립" parts = [x.strip() for x in v.split('/', 1)] try: pct_str = parts[0].rstrip('%').strip() out['pct'] = int(pct_str) except Exception: pass if len(parts) > 1: out['label'] = parts[1] return out def read_report(code: str, filename: str) -> str | None: d = _code_dir(code) # path traversal 방지 safe = ''.join(ch for ch in filename if ch.isalnum() or ch in '_-.') if safe != filename or not safe.endswith('.html'): return None p = d / safe if not p.exists() or not p.is_file(): return None return p.read_text() def save_report(code: str, body_html: str) -> Path: """보고서 저장. 같은 날 기존 보고서가 있으면 모두 삭제 후 새 파일 작성 (1일 1개 정책).""" d = _code_dir(code) today = now_kst().strftime('%Y-%m-%d') for old in d.glob(f'{today}_*.html'): try: old.unlink() except OSError: pass p = d / f'{ts_for_filename()}.html' p.write_text(body_html) return p # ============================================================================ # 비교업체 (peers) 저장소 # ============================================================================ PEERS_MAX = 2 class MultipleMatchError(Exception): """fuzzy resolve 결과 다중 후보. behive_web의 동명 클래스와 별개 — 데몬은 behive_web을 __main__으로 실행하므로 두 모듈 간 클래스 객체 분리됨. peers handler에서는 sa.MultipleMatchError를 잡아야 정확히 매칭된다. """ def __init__(self, query: str, candidates: list[dict]): self.query = query self.candidates = candidates super().__init__(query) _ALIAS_FALLBACK = { '엘지': 'LG', '에스케이': 'SK', '씨제이': 'CJ', '케이티': 'KT', '케이비': 'KB', '지에스': 'GS', '디엘': 'DL', '디비': 'DB', '에스비에스': 'SBS', '비지에프': 'BGF', '네이버': 'NAVER', '에이치엘': 'HL', '에이치디': 'HD', '오씨아이': 'OCI', '엔에이치엔': 'NHN', '비에이치': 'BH', '에이치엠엠': 'HMM', '케이씨씨': 'KCC', } def _load_aliases() -> dict[str, str]: """한글 음역 → 영문 prefix 매핑 (예: 엘지 → LG). 매번 파일 read (런타임 편집 반영). 파일 없거나 깨졌으면 코드 내 fallback. """ if ALIAS_FILE.exists(): try: data = json.loads(ALIAS_FILE.read_text()) # _ 로 시작하는 키(_note 등)는 제외 return {k: v for k, v in data.items() if not k.startswith('_') and isinstance(v, str)} except Exception: pass return _ALIAS_FALLBACK def _apply_alias(q: str) -> str: """입력 prefix에 한글 음역이 있으면 영문으로 치환. 가장 긴 매칭 우선. 예: '엘지전자' → 'LG전자', '엘지' → 'LG'. """ aliases = _load_aliases() for ko in sorted(aliases.keys(), key=len, reverse=True): if q.startswith(ko): return aliases[ko] + q[len(ko):] return q def _resolve_fuzzy(query: str) -> dict: """종목 매핑 단계: 1. case-sensitive exact 2. 6자리 숫자 코드 → 키움 직접 조회 3. case-insensitive exact (단일 채택) 4. prefix 단일 채택 5. substring (단일 채택, 다중이면 MultipleMatchError) 6. 한글 음역 적용 (예: 엘지전자 → LG전자) 후 1~5 재시도 7. 키움 직접 조회 '이름 좀 틀려도' UX: case-insensitive + prefix + 한글 음역까지 자동 매칭. """ q = (query or '').strip() if not q: raise ValueError('빈 입력') def _try_match(qv: str, allow_multiple_raise: bool = True) -> dict | None: """주어진 입력 문자열로 cache 매칭 시도. 못 찾으면 None. allow_multiple_raise=False면 다중 매칭에서 raise 안 하고 None 반환 (alias 재시도용 X — alias 결과는 단일이 자연스러우니 raise 허용).""" cache = kc._load_code_cache() # 1) exact (case-sensitive) if qv in cache: return cache[qv] ql = qv.lower() # 3) case-insensitive exact ci_exact = [info for name, info in cache.items() if name.lower() == ql] if len(ci_exact) == 1: return ci_exact[0] if len(ci_exact) > 1 and allow_multiple_raise: raise MultipleMatchError(qv, ci_exact[:30]) # 4) prefix 단일 prefix = [info for name, info in cache.items() if name.lower().startswith(ql)] if len(prefix) == 1: return prefix[0] # 5) substring matched = [info for name, info in cache.items() if ql in name.lower()] if len(matched) == 1: return matched[0] if len(matched) > 1 and allow_multiple_raise: 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(qv, matched[:30]) return None # 2) 6자리 코드 우선 if q.isdigit() and len(q) == 6: try: return kc.resolve_stock_code(q) except KeyError: raise ValueError(f'코드 "{q}" 조회 실패') # 1~5 시도 (다중 매칭이면 raise) try: hit = _try_match(q) if hit is not None: return hit except MultipleMatchError: # 다중이라도 alias 적용 시 단일이 될 수 있으니 alias 먼저 시도, 그래도 안 되면 raise aliased = _apply_alias(q) if aliased != q: try: hit2 = _try_match(aliased) if hit2 is not None: return hit2 except MultipleMatchError: pass # alias 적용해도 못 찾으면 원본 다중 매칭으로 raise raise # 6) 한글 음역 적용 후 재시도 aliased = _apply_alias(q) if aliased != q: hit = _try_match(aliased) if hit is not None: return hit # 7) 키움 직접 조회 try: return kc.resolve_stock_code(q) except (KeyError, AttributeError): raise ValueError(f'"{q}"와 일치하는 종목을 찾을 수 없어요. 종목명 또는 6자리 코드를 입력해주세요.') def _peers_path(code: str) -> Path: return _code_dir(code) / 'peers.json' def read_peers(code: str) -> list[dict]: """등록된 비교업체 list. 각 {code, name, added_at}.""" p = _peers_path(code) if not p.exists(): return [] try: data = json.loads(p.read_text()) return list(data.get('peers') or []) except Exception: return [] def _write_peers(code: str, peers: list[dict]) -> None: p = _peers_path(code) p.write_text(json.dumps({'peers': peers}, ensure_ascii=False, indent=2)) def add_peer(stock_code: str, query: str) -> dict: """query를 fuzzy resolve해 peer 추가. 반환: 추가된 peer dict. 에러: - ValueError: 빈 입력·조회 실패·자기 자신 추가·중복·정원 초과 - MultipleMatchError: 다중 매칭 — caller가 처리 (sa.MultipleMatchError로 잡을 것) """ stock_code = ''.join(ch for ch in str(stock_code) if ch.isalnum()) info = _resolve_fuzzy(query) peer_code = (info.get('code') or '').strip() peer_name = (info.get('name') or '').strip() if not peer_code: raise ValueError(f'"{query}" 조회 실패') if peer_code == stock_code: raise ValueError('자기 자신은 비교업체로 추가할 수 없어요.') peers = read_peers(stock_code) if any(p['code'] == peer_code for p in peers): raise ValueError(f'이미 추가된 종목입니다: {peer_name}') if len(peers) >= PEERS_MAX: raise ValueError(f'최대 {PEERS_MAX}개까지 추가할 수 있어요. 먼저 다른 종목을 빼주세요.') peer = {'code': peer_code, 'name': peer_name, 'added_at': now_kst().isoformat()} peers.append(peer) _write_peers(stock_code, peers) return peer def remove_peer(stock_code: str, peer_code: str) -> bool: """peer 1개 제거. 반환: 실제로 제거됐는지.""" stock_code = ''.join(ch for ch in str(stock_code) if ch.isalnum()) peer_code = ''.join(ch for ch in str(peer_code) if ch.isalnum()) peers = read_peers(stock_code) new_peers = [p for p in peers if p['code'] != peer_code] if len(new_peers) == len(peers): return False _write_peers(stock_code, new_peers) return True # ============================================================================ # SVG 차트 — 1년 캔들 + SMA 5/20/60 + 거래량 막대 # ============================================================================ CHART_W = 1000 CHART_H = 720 # crosshair 박스 영역(상단) + X축 날짜 라벨 영역 확보 CHART_PAD_L = 20 # 좌측 라벨 없음 (가격 라벨 우측으로 이동) CHART_PAD_R = 140 # 우측 가격·거래량 라벨 영역 (28px 폰트 = 6자리+쉼표 약 140px) CHART_PAD_T = 60 # SMA legend 영역 (y=4~50). OHLC 팝업은 차트 안 floating tooltip. PRICE_PANEL_H = 420 VOL_PANEL_H = 120 PANEL_GAP = 16 PRICE_TOP = CHART_PAD_T PRICE_BOT = CHART_PAD_T + PRICE_PANEL_H VOL_TOP = PRICE_BOT + PANEL_GAP VOL_BOT = VOL_TOP + VOL_PANEL_H CHART_RANGE_DAYS = {'1Y': 250, '6M': 120, '1M': 21} def render_svg_chart(snap: dict, range_key: str = '1Y') -> str: """캔들 + SMA 3선 + 거래량 막대 인라인 SVG. range_key: '1Y' (250일) | '6M' (120일) | '1M' (21일). 같은 snapshot 데이터에서 슬라이스만. snap: collect_snapshot 결과. candles_asc·sma5/20/60 사용. 외부 JS 의존성 0. CSS는 inline. """ candles_full = snap['candles_asc'] if not candles_full: return '
차트 데이터 없음
' n_take = CHART_RANGE_DAYS.get(range_key, 250) candles = candles_full[-n_take:] sma5 = (snap.get('sma5') or [])[-n_take:] sma20 = (snap.get('sma20') or [])[-n_take:] sma60 = (snap.get('sma60') or [])[-n_take:] # ---- 스케일 ---- highs = [c['high'] for c in candles] lows = [c['low'] for c in candles] # SMA 값도 가격축 범위에 포함 (선이 잘리지 않도록) sma_vals = [v for v in (sma5 + sma20 + sma60) if v is not None] y_max = max(max(highs), max(sma_vals) if sma_vals else 0) y_min = min(min(lows), min(sma_vals) if sma_vals else float('inf')) if y_min == y_max: y_max = y_min + 1 # 위·아래 패딩 5% span = y_max - y_min y_max += span * 0.05 y_min -= span * 0.02 vols = [c['volume'] for c in candles] v_max = max(vols) if vols else 1 n = len(candles) plot_w = CHART_W - CHART_PAD_L - CHART_PAD_R slot = plot_w / n body_w = max(1.2, slot * 0.7) def price_y(price: float) -> float: # y 위에서 아래: 큰 가격이 위 frac = (price - y_min) / (y_max - y_min) return PRICE_BOT - frac * (PRICE_BOT - PRICE_TOP) def vol_y(v: float) -> float: frac = (v / v_max) if v_max > 0 else 0 return VOL_BOT - frac * (VOL_BOT - VOL_TOP) def slot_x(i: int) -> float: return CHART_PAD_L + slot * (i + 0.5) # crosshair 인터랙션용 메타 — 클라이언트가 봉 index/가격 매핑할 때 사용. import json as _json import html as _h meta_obj = { 'candles': [ {'d': c['date'], 'o': c['open'], 'h': c['high'], 'l': c['low'], 'c': c['close']} for c in candles ], 'ymin': y_min, 'ymax': y_max, 'priceTop': PRICE_TOP, 'priceBot': PRICE_BOT, 'volTop': VOL_TOP, 'volBot': VOL_BOT, 'padL': CHART_PAD_L, 'padR': CHART_PAD_R, 'chartW': CHART_W, 'chartH': CHART_H, } meta_attr = _h.escape(_json.dumps(meta_obj, separators=(',', ':')), quote=True) parts: list[str] = [] parts.append( f'' ) # ---- 가격 패널 배경 + 격자 ---- parts.append( f'' ) # 가격 가로 격자 5개 + Y축 라벨 for k in range(6): frac = k / 5 y = PRICE_TOP + frac * PRICE_PANEL_H price = y_max - frac * (y_max - y_min) parts.append( f'' ) parts.append( f'' f'{int(round(price)):,}' ) # ---- 캔들 ---- for i, c in enumerate(candles): x = slot_x(i) o, h, l, cl = c['open'], c['high'], c['low'], c['close'] up = cl >= o color = '#ef4444' if up else '#3b82f6' # 한국 관습: 상승=빨강, 하락=파랑 # 심지 parts.append( f'' ) # 몸통 top = price_y(max(o, cl)) bot = price_y(min(o, cl)) height = max(0.8, bot - top) parts.append( f'' ) # ---- SMA 선 ---- def sma_path(values: list[float | None], color: str, name: str) -> str: pts = [] for i, v in enumerate(values): if v is None: if pts and pts[-1] is not None: pts.append(None) # 끊김 표시 continue pts.append((slot_x(i), price_y(v))) # path 구성 — None 만나면 새 sub-path d_parts: list[str] = [] prev_was_pt = False for p in pts: if p is None: prev_was_pt = False continue x, y = p if not prev_was_pt: d_parts.append(f'M{x:.2f},{y:.2f}') prev_was_pt = True else: d_parts.append(f'L{x:.2f},{y:.2f}') if not d_parts: return '' d = ' '.join(d_parts) return f'' parts.append(sma_path(sma5, '#fbbf24', 'SMA5')) # 노랑 parts.append(sma_path(sma20, '#34d399', 'SMA20')) # 초록 parts.append(sma_path(sma60, '#a78bfa', 'SMA60')) # 보라 # SMA 범례 legend_y = PRICE_TOP + 14 legend_items = [('SMA5', '#fbbf24'), ('SMA20', '#34d399'), ('SMA60', '#a78bfa')] lx = CHART_PAD_L + 8 for name, color in legend_items: parts.append( f'' ) parts.append( f'{name}' ) lx += 70 # ---- 거래량 패널 배경 ---- parts.append( f'' ) # 거래량 막대 for i, c in enumerate(candles): x = slot_x(i) v = c['volume'] if v <= 0: continue bar_top = vol_y(v) bar_h = VOL_BOT - bar_top up = c['close'] >= c['open'] color = '#ef4444' if up else '#3b82f6' parts.append( f'' ) # 거래량 Y 레이블 (max 만) — 우측 parts.append( f'' f'{_fmt_vol(v_max)}' ) # ---- X축 날짜 (시작·중간·끝) ---- for i in (0, n // 2, n - 1): x = slot_x(i) # 첫/끝 라벨이 plot 밖으로 안 잘리도록 anchor 분기. anchor = 'start' if i == 0 else ('end' if i == n - 1 else 'middle') parts.append( f'' f'{_fmt_date(candles[i]["date"])}' ) parts.append('') return ''.join(parts) def _fmt_vol(v: int) -> str: if v >= 100_000_000: return f'{v/100_000_000:.1f}억' if v >= 10_000: return f'{v/10_000:.0f}만' return f'{v:,}' # ============================================================================ # LLM 호출 — openclaw capability model run subprocess # ============================================================================ def load_prompt_template() -> str: """매 호출마다 prompts/stock_analysis.md를 읽어 반환. 런타임 편집 즉시 반영 위해 캐시 없음 (파일 작아서 부담 없음). 파일 없거나 {data_block} 자리 표시자 누락 시 코드 내 FALLBACK 사용. """ if PROMPT_FILE.exists(): try: text = PROMPT_FILE.read_text() if '{data_block}' in text: return text sys.stderr.write(f'[stock_analysis] {PROMPT_FILE} 에 {{data_block}} 자리표시자 없음 → fallback\n') except Exception as e: sys.stderr.write(f'[stock_analysis] {PROMPT_FILE} 읽기 실패 ({e}) → fallback\n') return _PROMPT_FALLBACK _PROMPT_FALLBACK = """당신은 한국 주식 종목분석가입니다. 아래 키움증권 실시간 데이터를 보고 관리자(개인 투자자)를 위한 분석 보고서를 작성합니다. ## 작성 규칙 - 한국어, 존댓말, 관리자님 호칭. - 4개 섹션 순서·제목 그대로 사용 (▣ 최근 가격 흐름 / ▣ 수급 해석 / ▣ 거래량·체결 강도 / ▣ 주의 포인트 / 리스크). - 각 섹션 5문장 이상, 수치 근거 인용. - 비교업체(peers) 데이터는 종목의 기본정보(가격·PER/PBR/ROE 등 재무비율) 관련 맥락에서만 간단히 짚어주시고, 4개 분석 섹션(가격 흐름·수급·거래량·주의 포인트) 안에서는 비교 코멘트를 넣지 마세요. - 마지막에 한줄요약·투자의견 JSON을 정확한 형식으로 출력 (파서가 읽음). ## 종목 데이터 {data_block} ## 출력 형식 (이 순서·구분자 그대로) ONELINE: <30자 이내 한 줄 요약. 추세/수급/거래량 핵심> ▣ 최근 가격 흐름 <5문장 이상> ▣ 수급 해석 <5문장 이상> ▣ 거래량·체결 강도 <5문장 이상> ▣ 주의 포인트 / 리스크 <5문장 이상> VERDICT_JSON: {{"recommendation_pct": <0~100 정수>, "label": "<추천 안 함|관망|중립|매수 검토|강력 추천 중 하나>", "one_line": "<한 줄 결론 40자 이내>"}} 추천도 기준: - 0~20: 추천 안 함 (단기 매도/접근 비추천) - 21~40: 관망 (부정 신호 우세) - 41~60: 중립 - 61~80: 매수 검토 (긍정 신호 우세) - 81~100: 강력 추천 (강한 매수 신호) """ def _build_data_block(snap: dict) -> str: """LLM 입력용 데이터 텍스트 (Markdown). 토큰 절약 위해 간결하게.""" b = snap['basic'] fs = snap['flow_summary'] flow = snap['flow'][:20] # 20일치 candles = snap['candles_asc'] # 가격 추이 핵심 — 최근 20일 종가 (간격 1) + 60일·120일·1년 전 종가 closes = [c['close'] for c in candles] dates = [c['date'] for c in candles] def at(idx_from_end: int) -> str: i = len(closes) - 1 - idx_from_end if i < 0: return 'N/A' return f'{_fmt_date(dates[i])} {closes[i]:,}원' sma5 = snap['sma5'][-1] sma20 = snap['sma20'][-1] sma60 = snap['sma60'][-1] lines = [] lines.append(f'### 종목: {snap["name"]} ({snap["code"]})') lines.append(f'- 현재가: {b["price"]:,}원 ({b["change"]:+,} / {b["change_pct"]:+.2f}%)') lines.append(f'- 오늘 시고저: 시 {b["open"]:,} / 고 {b["high"]:,} / 저 {b["low"]:,}') lines.append(f'- 시가총액: {b["market_cap"]:,} (단위 키움원본) · 상장주식수: {b["listed_shares"]:,}주') lines.append(f'- 52주 고저: 고 {b["high_52w"]:,}원 ({_fmt_date(b["high_52w_dt"])}) / 저 {b["low_52w"]:,}원 ({_fmt_date(b["low_52w_dt"])})') lines.append(f'- 회전율(오늘): {b["turnover_rate"]:.2f}% · 외국인 보유비율: {b["foreign_holding_pct"]:.2f}%') lines.append(f'- PER {b["per"]} · PBR {b["pbr"]} · ROE {b["roe"]} · EPS {b["eps"]:,} · BPS {b["bps"]:,}') lines.append('') lines.append('### 이동평균 (최신 기준)') lines.append(f'- SMA5: {round(sma5):,}원' if sma5 else '- SMA5: N/A') lines.append(f'- SMA20: {round(sma20):,}원' if sma20 else '- SMA20: N/A') lines.append(f'- SMA60: {round(sma60):,}원' if sma60 else '- SMA60: N/A') lines.append('') lines.append('### 가격 추이') lines.append(f'- 오늘: {at(0)}') lines.append(f'- 5일전: {at(5)}') lines.append(f'- 20일전: {at(20)}') lines.append(f'- 60일전: {at(60)}') lines.append(f'- 120일전: {at(120)}') lines.append(f'- 1년전: {at(min(240, len(closes)-1))}') lines.append('') lines.append('### 최근 20일 외국인·기관 수급 (단위: 주, +는 순매수)') lines.append(f'- 외국인 20일 누적: {fs["foreign_net"]*1000:+,}주 · 매수일 {fs["foreign_buy_days"]}/20일') lines.append(f'- 기관 20일 누적: {fs["institution_net"]*1000:+,}주 · 매수일 {fs["institution_buy_days"]}/20일') lines.append(f'- 개인 20일 누적: {fs["individual_net"]*1000:+,}주') lines.append('') lines.append('일별 수급 (최신 → 옛, 외/기/개, 단위: 주):') for r in flow: lines.append(f' {r["date"]}: 외 {r["foreign"]*1000:+,} / 기 {r["institution"]*1000:+,} / 개 {r["individual"]*1000:+,}') lines.append('') lines.append('### 최근 20일 거래량 (단위: 주)') for c in candles[-20:][::-1]: lines.append(f' {c["date"]}: 종가 {c["close"]:,} / 거래량 {c["volume"]:,} / 회전 {c["turnover_rate"]}%') if snap.get('holding'): h = snap['holding'] lines.append('') lines.append('### 내 포지션') lines.append(f'- 평균단가: {h.get("avg_price", 0):,}원') lines.append(f'- 보유수량: {h.get("qty", 0):,}주') lines.append(f'- 수익률: {h.get("profit_pct", 0):+.2f}%') fund = snap.get('fundamentals') if fund: annual = fund.get('annual') or [] cons = fund.get('consensus') or {} if annual: lines.append('') lines.append('### 연간 실적·성장성 (FnGuide, 단위: 매출/영업이익 억원·EPS 원, 증가율 YoY%)') for r in annual[-5:]: def _f(v): return f'{round(v):,}' if isinstance(v, (int, float)) else 'N/A' def _g(v): return f'{v:+.1f}%' if isinstance(v, (int, float)) else 'N/A' lines.append( f' {r.get("period","")}: 매출 {_f(r.get("sales"))}({_g(r.get("sales_growth"))}) · ' f'영업이익 {_f(r.get("oper_profit"))}({_g(r.get("oper_profit_growth"))}) · ' f'EPS {_f(r.get("eps"))}({_g(r.get("eps_growth"))}) · ROE {r.get("roe","N/A")}' ) if cons: lines.append('') lines.append('### 컨센서스 (FnGuide, 증권사 추정 종합)') lines.append( f'- 목표주가 {_fmt_num(round(cons["target_price"])) if isinstance(cons.get("target_price"),(int,float)) else "N/A"}원 · ' f'투자의견 {cons.get("opinion_label","N/A")}({cons.get("opinion","N/A")}/5) · ' f'추정 EPS {_fmt_num(round(cons["eps"])) if isinstance(cons.get("eps"),(int,float)) else "N/A"}원 · ' f'추정 PER {cons.get("per","N/A")}배 · 참여기관 {int(cons["organ_count"]) if isinstance(cons.get("organ_count"),(int,float)) else "N/A"}개' ) cons = snap.get('consensus') if cons: annual = cons.get('annual') or [] rev = cons.get('revision') or {} sup = (cons.get('surprise') or {}).get('items') or {} if annual: lines.append('') lines.append('### WISEreport 연도별 추정 재무 (IFRS연결, A=실적 E=추정, 매출/영업이익 억원·EPS 원)') for r in annual: def _f(v): return f'{round(v):,}' if isinstance(v, (int, float)) else 'N/A' ae = 'E' if r.get('is_estimate') else 'A' yoy = r.get('sales_yoy') lines.append( f' {r.get("period","")}({ae}): 매출 {_f(r.get("sales"))}' f'(YoY {yoy:+.1f}%)' if isinstance(yoy, (int, float)) else f' {r.get("period","")}({ae}): 매출 {_f(r.get("sales"))}' ) lines[-1] += (f' · 영업이익 {_f(r.get("op"))} · EPS {_f(r.get("eps"))} · ROE {r.get("roe","N/A")}') if rev and (rev.get('target_change_pct') is not None or rev.get('est_change_pct') is not None): lines.append('') lines.append('### 컨센서스 리비전 (최근 3개월 추이)') lines.append( f'- 목표주가 {rev.get("target_first")} → {rev.get("target_last")} ' f'({rev.get("target_change_pct")}%) · ' f'{rev.get("item_name","추정")} {rev.get("est_first")} → {rev.get("est_last")} ' f'({rev.get("est_change_pct")}%)' ) if sup: lines.append('') lines.append('### 어닝 서프라이즈 (최신 확정연도 실적 vs 직전 컨센서스 괴리율)') for label, v in sup.items(): if v.get('fy0_surprise_pct') is None: continue lines.append(f'- {label} {v.get("fy0_year","")}: 실적 {v.get("fy0_actual")} ' f'(서프라이즈 {v.get("fy0_surprise_pct"):+.2f}%)') peers = snap.get('peers') or [] if peers: lines.append('') lines.append('### 비교업체 (사용자가 등록한 종목)') for p in peers: if p.get('error'): lines.append(f'- {p["name"]} ({p["code"]}): 조회 실패 ({p["error"]})') continue lines.append( f'- {p["name"]} ({p["code"]}): {p["price"]:,}원 ' f'({p["change"]:+,} / {p["change_pct"]:+.2f}%) · ' f'PER {p["per"]} · PBR {p["pbr"]} · ROE {p["roe"]}%' ) return '\n'.join(lines) def call_llm(prompt: str, timeout: int = LLM_TIMEOUT_SEC) -> str: """openclaw capability model run subprocess. 실패 시 RuntimeError raise.""" cmd = [ 'openclaw', 'capability', 'model', 'run', '--prompt', prompt, '--model', LLM_MODEL, '--json', ] try: p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) except subprocess.TimeoutExpired: raise RuntimeError(f'LLM 호출 timeout ({timeout}s)') if p.returncode != 0: raise RuntimeError(f'LLM 호출 실패 rc={p.returncode}: {p.stderr[:300]}') try: data = json.loads(p.stdout) except json.JSONDecodeError as e: raise RuntimeError(f'LLM 응답 JSON 파싱 실패: {e}; stdout[:300]={p.stdout[:300]}') if not data.get('ok'): raise RuntimeError(f'LLM 응답 ok=false: {data}') outs = data.get('outputs') or [] if not outs: raise RuntimeError('LLM 응답 outputs 비어있음') text = (outs[0].get('text') or '').strip() if not text: raise RuntimeError('LLM 응답 text 비어있음') return text # ============================================================================ # LLM 응답 파싱 — ONELINE / 4섹션 / VERDICT_JSON # ============================================================================ def parse_llm_output(text: str) -> dict: """LLM raw text를 dict로. 형식 위반 시 best-effort fallback. 반환: {oneline, sections[4], verdict: {recommendation_pct, label, one_line}, raw} """ lines = text.splitlines() oneline = '' sections: list[dict] = [] current: dict | None = None verdict = {'recommendation_pct': 50, 'label': '중립', 'one_line': ''} i = 0 while i < len(lines): line = lines[i] stripped = line.strip() if stripped.startswith('ONELINE:'): oneline = stripped[len('ONELINE:'):].strip() i += 1 continue if stripped.startswith('VERDICT_JSON:'): # 같은 줄 JSON 시도 json_part = stripped[len('VERDICT_JSON:'):].strip() if not json_part: # 다음 줄들 합쳐서 JSON 찾기 buf = [] j = i + 1 while j < len(lines): buf.append(lines[j]) j += 1 json_part = '\n'.join(buf).strip() # 코드펜스 제거 if json_part.startswith('```'): json_part = json_part.split('\n', 1)[1] if '\n' in json_part else '' if json_part.endswith('```'): json_part = json_part.rsplit('```', 1)[0] json_part = json_part.strip() try: v = json.loads(json_part) verdict['recommendation_pct'] = max(0, min(100, int(v.get('recommendation_pct', 50)))) verdict['label'] = str(v.get('label') or _verdict_label(verdict['recommendation_pct'])) verdict['one_line'] = str(v.get('one_line') or '') except Exception: pass break # VERDICT_JSON 뒤는 없음 if stripped.startswith('▣'): if current: sections.append(current) current = {'title': stripped, 'body': ''} i += 1 continue if current is not None: current['body'] += line + '\n' i += 1 if current: sections.append(current) # body trim for s in sections: s['body'] = s['body'].strip() # 라벨 보정 (LLM이 비표준 라벨 주면 % 기준으로 결정) if verdict['label'] not in ('추천 안 함', '관망', '중립', '매수 검토', '강력 추천'): verdict['label'] = _verdict_label(verdict['recommendation_pct']) return { 'oneline': oneline, 'sections': sections, 'verdict': verdict, 'raw': text, } def _verdict_label(pct: int) -> str: if pct <= 20: return '추천 안 함' if pct <= 40: return '관망' if pct <= 60: return '중립' if pct <= 80: return '매수 검토' return '강력 추천' # ============================================================================ # 보고서 HTML 빌더 # ============================================================================ def _esc(s) -> str: return (str(s).replace('&', '&').replace('<', '<').replace('>', '>') .replace('"', '"').replace("'", ''')) def _fmt_num(v) -> str: try: n = int(v) return f'{n:,}' except Exception: return str(v) def _fmt_pct_signed(v: float) -> str: return f'{v:+.2f}%' def _fmt_date(s) -> str: """20260519 → 2026-05-19. 형식 다르면 원본 반환.""" s = str(s or '').strip() if len(s) == 8 and s.isdigit(): return f'{s[:4]}-{s[4:6]}-{s[6:]}' return s def _fmt_card_ts(ts: str) -> str: """2026-05-19_17-09-50 → 2026-05-19 17:09. 보고서 카드용.""" if not ts: return '' if '_' in ts: d, t = ts.split('_', 1) # t는 17-09-50. 분까지만, : 로 tp = t.split('-') return f'{d} {tp[0]}:{tp[1]}' if len(tp) >= 2 else f'{d} {t}' return ts _KV_HINTS = { '현재가': '지금 거래되고 있는 가격이에요. 옆 숫자는 어제 종가 대비 얼마나 움직였는지(절대값/%)예요.', '오늘 시가': '오늘 장 시작할 때 첫 거래 가격이에요.', '오늘 고가': '오늘 장중 가장 높았던 가격이에요.', '오늘 저가': '오늘 장중 가장 낮았던 가격이에요.', '시가총액': '회사 전체 주식 수 × 현재가. 회사의 시장 규모예요. 클수록 대형주.', '상장주식수': '시장에 풀려 있는 총 주식 수예요.', '52주 최고': '최근 1년(약 250 거래일) 안에 가장 비쌌던 가격이에요.', '52주 최저': '최근 1년 안에 가장 쌌던 가격이에요.', '회전율 (오늘)': '오늘 거래량 ÷ 상장주식수 × 100. 거래가 활발할수록 높아요. 보통 1% 넘으면 활기.', 'PER': '주가수익비율 (Price / EPS). 주가가 1주당 이익의 몇 배인지. 낮을수록 저평가(일반론). 업종마다 평균이 달라요.', 'PBR': '주가순자산비율 (Price / BPS). 주가가 1주당 순자산의 몇 배인지. 1보다 낮으면 청산가치 이하라는 의미예요.', 'ROE': '자기자본이익률. 회사가 가진 자본으로 1년에 얼마를 벌었는지(%). 높을수록 자본 활용 효율이 좋아요. 10% 이상이면 양호.', 'EPS': '주당순이익. 1주당 회사가 번 순이익(원).', 'BPS': '주당순자산. 1주당 회사가 가진 순자산 가치(원).', '외국인 보유비율': '전체 발행 주식 중 외국인이 보유한 비율(%).', # ── 성장성·펀더멘털 (FnGuide) ── '매출액(억)': '회사가 1년에 물건·서비스를 팔아 벌어들인 총액(억원). 회사 덩치가 커지는지 보는 지표.', '매출증가율': '작년보다 매출이 몇 % 늘었는지. +면 성장, −면 역성장. 꾸준히 +면 좋아요.', '영업이익(억)': '본업으로 번 이익(억원). 매출에서 원가·판관비를 뺀 것. 회사가 장사를 잘하는지.', 'EPS(원)': '주당순이익. 1주가 1년에 번 순이익(원). 높을수록, 그리고 늘어날수록 좋아요.', 'EPS증가율': '작년보다 주당순이익이 몇 % 늘었는지. 이익이 실제로 커지는지 보는 핵심 지표.', '목표주가': '증권사들이 "이 정도까진 오를 만하다"고 본 가격의 평균. 현재가보다 높으면 상승 기대.', '투자의견': '증권사들의 종합 의견. 1=매도 … 5=적극매수. 숫자가 클수록 사라는 쪽.', '추정 EPS': '증권사들이 예상한 올해(미래) 주당순이익. 앞으로 얼마 벌지 전망치예요.', '추정 PER': '추정 이익 기준 PER(주가÷예상EPS). 미래 이익 대비 주가가 비싼지 싼지.', '참여 기관수': '이 컨센서스(평균)를 만든 증권사 수. 많을수록 신뢰도가 높아요.', # ── 컨센서스 (WISEreport) ── 'YoY': '전년 대비(Year over Year) 증가율(%). 1년 전 같은 기간과 비교한 변화예요.', '목표주가 리비전': '최근 3개월간 증권사 목표주가 평균이 오르고(+) 있는지 내리고(−) 있는지. 오르는 중이면 시장 기대가 좋아지는 신호.', '추정치 리비전': '최근 3개월간 예상 실적(EPS 등)이 상향(+)/하향(−)되는 중인지. 상향이면 전망이 밝아지는 거예요.', '서프라이즈': '실제 발표 실적이 직전 예상치보다 잘 나왔으면(+) 어닝 서프라이즈, 못 나왔으면(−) 어닝 쇼크.', '실적(억)': '실제로 확정된 작년 실적 금액(억원). 예상치가 아니라 진짜 나온 숫자.', } def _lbl(label: str) -> str: """라벨에 설명(ⓘ 탭 팝업)이 있으면 감싸고, 없으면 그냥 텍스트. 새 표 헤더용.""" hint = _KV_HINTS.get(label) if hint: return f'{_esc(label)}' return _esc(label) def _kv_row(label: str, value_html: str) -> str: """th 라벨. hint 있으면 누름/뗌 팝업으로 띄우는 span. 없으면 plain text.""" hint = _KV_HINTS.get(label) if hint: label_html = ( f'{_esc(label)}' ) else: label_html = _esc(label) return f'{label_html}{value_html}' def _verdict_color(pct: int) -> str: if pct <= 20: return '#ef4444' # 빨강 if pct <= 40: return '#f97316' # 주황 if pct <= 60: return '#9ca3af' # 회색 if pct <= 80: return '#22c55e' # 초록 return '#16a34a' # 진초록 def _render_verdict_bar(pct: int, label: str, one_line: str) -> str: """추천도 게이지 막대 SVG.""" pct = max(0, min(100, int(pct))) color = _verdict_color(pct) bar_w = 600 bar_h = 28 fill_w = bar_w * pct / 100 # 구간 경계선 (20·40·60·80%) boundaries = [] for b in (20, 40, 60, 80): x = bar_w * b / 100 boundaries.append( f'' ) svg = ( f'' f'' f'' + ''.join(boundaries) + f'{pct}% · {_esc(label)}' + f'0 추천 안 함' + f'100 강력 추천' + '' ) return svg def _fmt_growth(v) -> str: """증가율(%) → 색상 포함 셀. 빨강=증가/파랑=감소 (국내 관행).""" if not isinstance(v, (int, float)): return '' color = '#ef4444' if v > 0 else ('#3b82f6' if v < 0 else '#9ca3af') return f'{v:+.1f}%' def _render_fundamentals_html(fund: dict | None) -> str: """FnGuide 성장성·컨센서스 섹션. fund 없으면 빈 문자열(섹션 자체 생략).""" if not fund: return '' annual = fund.get('annual') or [] consensus = fund.get('consensus') or {} # ---- 연간 성장성 표 (최근 5개 연도) ---- table_html = '' if annual: rows = annual[-5:] body = [] for r in rows: sales = r.get('sales') oper = r.get('oper_profit') eps = r.get('eps') roe = r.get('roe') body.append( '' f'{_esc(r.get("period",""))}' f'{_fmt_num(round(sales)) if isinstance(sales,(int,float)) else "—"}' f'{_fmt_growth(r.get("sales_growth"))}' f'{_fmt_num(round(oper)) if isinstance(oper,(int,float)) else "—"}' f'{_fmt_num(round(eps)) if isinstance(eps,(int,float)) else "—"}' f'{_fmt_growth(r.get("eps_growth"))}' f'{(str(roe)+"%") if isinstance(roe,(int,float)) else "—"}' '' ) table_html = ( '' '' f'' f'' f'' '' '' + ''.join(body) + '
기간{_lbl("매출액(억)")}{_lbl("매출증가율")}{_lbl("영업이익(억)")}{_lbl("EPS(원)")}{_lbl("EPS증가율")}{_lbl("ROE")}
' ) # ---- 컨센서스 카드 ---- cons_html = '' if consensus: tp = consensus.get('target_price') op_label = consensus.get('opinion_label') op = consensus.get('opinion') ce = consensus.get('eps') cper = consensus.get('per') oc = consensus.get('organ_count') parts = [] if isinstance(tp, (int, float)): parts.append(f'{_lbl("목표주가")}{_fmt_num(round(tp))}원') if op_label: parts.append(f'{_lbl("투자의견")}{_esc(op_label)} ' f'({op}/5)') if isinstance(ce, (int, float)): parts.append(f'{_lbl("추정 EPS")}{_fmt_num(round(ce))}원') if isinstance(cper, (int, float)): parts.append(f'{_lbl("추정 PER")}{cper}배') if isinstance(oc, (int, float)): parts.append(f'{_lbl("참여 기관수")}{int(oc)}개') if parts: cdate = consensus.get('date') date_html = (f' · 기준 {_esc(cdate)}' if cdate else '') cons_html = ( f'

📋 컨센서스{date_html}

' '' + ''.join(parts) + '
' ) if not table_html and not cons_html: return '' note = ('

' '출처: FnGuide 컴퍼니가이드 · 증가율은 전년대비(YoY)

') return ( '
' '

📈 성장성 · 컨센서스

' '

회사가 해마다 얼마나 더 벌고 있는지(성장성)와, 증권사들이 본 적정 주가·이익 전망(컨센서스)이에요. ' '밑줄 친 항목 이름()을 누르면 쉬운 설명이 떠요.

' + table_html + cons_html + note + '
' ) def _render_consensus_html(cons: dict | None) -> str: """WISEreport 컨센서스 섹션 — 연도별 추정표 + 목표가 리비전 + 어닝 서프라이즈.""" if not cons: return '' annual = cons.get('annual') or [] rev = cons.get('revision') or {} sup = cons.get('surprise') or {} parts = [] # ---- 목표주가 리비전 (3개월 추이) ---- if rev: tchg = rev.get('target_change_pct') echg = rev.get('est_change_pct') bits = [] if isinstance(rev.get('target_first'), (int, float)) and isinstance(rev.get('target_last'), (int, float)): bits.append( f'목표주가 {_fmt_num(round(rev["target_first"]))} → ' f'{_fmt_num(round(rev["target_last"]))}원 {_fmt_growth(tchg)}' ) if isinstance(rev.get('est_first'), (int, float)) and isinstance(rev.get('est_last'), (int, float)): nm = _esc(rev.get('item_name') or '추정') bits.append(f'{nm} {_fmt_num(round(rev["est_first"]))} → ' f'{_fmt_num(round(rev["est_last"]))} {_fmt_growth(echg)}') if bits: n = rev.get('n_points') parts.append( '

🔁 목표주가·추정치 리비전 ' f'(최근 {n}주)

' '

' + ' · '.join(bits) + '

' ) # ---- 어닝 서프라이즈 ---- items = sup.get('items') or {} sup_rows = [] for label, v in items.items(): if v.get('fy0_surprise_pct') is None and v.get('fy0_actual') is None: continue yr = v.get('fy0_year') or '' act = v.get('fy0_actual') sp = v.get('fy0_surprise_pct') sup_rows.append( '' f'{_esc(label)}' f'{_fmt_num(round(act)) if isinstance(act,(int,float)) else "—"}' f'{_fmt_growth(sp)}' '' ) if sup_rows: any_year = next((v.get('fy0_year') for v in items.values() if v.get('fy0_year')), '') parts.append( f'

🎯 어닝 서프라이즈 ' f'({_esc(any_year)} 실적 vs 직전 컨센서스)

' '' f'' '' + ''.join(sup_rows) + '
항목{_lbl("실적(억)")}{_lbl("서프라이즈")}
' ) # ---- 연도별 추정 재무 (A 실적 + E 추정) ---- if annual: body = [] for r in annual: est = r.get('is_estimate') tag = (' (E)' if est else ' (A)') sales = r.get('sales'); op = r.get('op'); eps = r.get('eps'); roe = r.get('roe') body.append( '' f'{_esc(r.get("period",""))}{tag}' f'{_fmt_num(round(sales)) if isinstance(sales,(int,float)) else "—"}' f'{_fmt_growth(r.get("sales_yoy"))}' f'{_fmt_num(round(op)) if isinstance(op,(int,float)) else "—"}' f'{_fmt_num(round(eps)) if isinstance(eps,(int,float)) else "—"}' f'{(str(roe)+"%") if isinstance(roe,(int,float)) else "—"}' '' ) parts.append( '

📅 연도별 추정 재무 ' '(IFRS연결 · A 실적 / E 추정)

' '' f'' f'' '' + ''.join(body) + '
기간{_lbl("매출액(억)")}{_lbl("YoY")}{_lbl("영업이익(억)")}{_lbl("EPS(원)")}{_lbl("ROE")}
' ) if not parts: return '' note = ('

' '출처: FnGuide WISEreport 컨센서스(증권사 추정 3개월 평균). ' '추정 절대값은 데이터원 원본이며 실제 스케일과 다를 수 있습니다(방향성·괴리율 위주로 해석).

') return ( '
' '

🔮 컨센서스 (추정·리비전·서프라이즈)

' '

증권사들이 본 미래 실적 전망이에요. ' '리비전=그 전망이 최근 3개월간 좋아지는지(+)/나빠지는지(−), ' '서프라이즈=지난 실적이 예상보다 잘 나왔는지(+)/못 나왔는지(−), ' '연도별 추정=해마다 얼마 벌지 예상치(E)와 실제(A). 밑줄 항목()을 누르면 설명이 떠요.

' + ''.join(parts) + note + '
' ) def build_report_html(snap: dict, parsed: dict) -> str: """전체 보고서 HTML 단편 빌드. behive_web 페이지에 그대로 임베드. 상단 6줄 주석에 메타정보 (oneline·recommendation·generated_at) 박아 list_reports가 파싱. """ b = snap['basic'] fs = snap['flow_summary'] code = snap['code'] name = snap['name'] ts = ts_for_display() verdict = parsed['verdict'] pct = verdict['recommendation_pct'] label = verdict['label'] oneline = parsed['oneline'] or verdict.get('one_line', '') holding = snap.get('holding') # 메타 주석 (첫 6줄 안에) — list_reports가 파싱 head_meta = ( f'\n' f'\n' f'\n' ) # ---- 기본 정보 카드 ---- # peers가 있으면 본 종목 + peer 컬럼을 한 표에 가로 배치, 없으면 기존 kvtable 유지. peers = snap.get('peers') or [] change_color = '#ef4444' if b['change'] > 0 else ('#3b82f6' if b['change'] < 0 else '#9ca3af') def _peer_price_cell(p: dict) -> str: if p.get('error'): return '조회 실패' pc = '#ef4444' if p['change'] > 0 else ('#3b82f6' if p['change'] < 0 else '#9ca3af') return (f'{p["price"]:,}원 ' f'({_fmt_pct_signed(p["change_pct"])})') def _peer_simple(p: dict, field: str, suffix: str) -> str: if p.get('error'): return '조회 실패' v = p.get(field) return f'{v}{suffix}' if v is not None else '—' def _peer_int(p: dict, field: str, suffix: str) -> str: if p.get('error'): return '조회 실패' v = p.get(field) return f'{v:,}{suffix}' if isinstance(v, (int, float)) else '—' def _peer_pct(p: dict, field: str) -> str: if p.get('error'): return '조회 실패' v = p.get(field) return f'{v:.2f}%' if isinstance(v, (int, float)) else '—' def _peer_52w(p: dict, value_field: str, date_field: str) -> str: if p.get('error'): return '조회 실패' v = p.get(value_field) if not isinstance(v, (int, float)): return '—' dt = p.get(date_field) date_html = f' ({_fmt_date(dt)})' if dt else '' return f'{v:,}원{date_html}' # (label, self_value_html, peer_value_fn or None) basic_rows: list[tuple[str, str, callable | None]] = [ ('현재가', f'{b["price"]:,}원 ' f'({b["change"]:+,} / {_fmt_pct_signed(b["change_pct"])})', _peer_price_cell), ('오늘 시가', f'{b["open"]:,}원', lambda p: _peer_int(p, 'open', '원')), ('오늘 고가', f'{b["high"]:,}원', lambda p: _peer_int(p, 'high', '원')), ('오늘 저가', f'{b["low"]:,}원', lambda p: _peer_int(p, 'low', '원')), ('시가총액', f'{b["market_cap"]:,} (키움 원본 단위)', lambda p: _peer_int(p, 'market_cap', '')), ('상장주식수', f'{b["listed_shares"]:,}주', lambda p: _peer_int(p, 'listed_shares', '주')), ('52주 최고', f'{b["high_52w"]:,}원 ({_fmt_date(b["high_52w_dt"])})', lambda p: _peer_52w(p, 'high_52w', 'high_52w_dt')), ('52주 최저', f'{b["low_52w"]:,}원 ({_fmt_date(b["low_52w_dt"])})', lambda p: _peer_52w(p, 'low_52w', 'low_52w_dt')), ('회전율 (오늘)', f'{b["turnover_rate"]:.2f}%', lambda p: _peer_pct(p, 'turnover_rate')), ('PER', f'{b["per"]}배', lambda p: _peer_simple(p, 'per', '배')), ('PBR', f'{b["pbr"]}배', lambda p: _peer_simple(p, 'pbr', '배')), ('ROE', f'{b["roe"]}%', lambda p: _peer_simple(p, 'roe', '%')), ('EPS', f'{b["eps"]:,}원', lambda p: _peer_int(p, 'eps', '원')), ('BPS', f'{b["bps"]:,}원', lambda p: _peer_int(p, 'bps', '원')), ('외국인 보유비율', f'{b["foreign_holding_pct"]:.2f}%', lambda p: _peer_pct(p, 'foreign_holding_pct')), ] if peers: # 본 종목 + peer 컬럼이 있는 가로 표. 비교 불가 행은 peer 셀에 dash. header_cells = [ '', f'{_esc(name)} [본 종목]', ] for p in peers: pname = _esc(p['name']) pcode = _esc(p['code']) header_cells.append(f'{pname} ({pcode})') body_rows: list[str] = [] for label, self_v, peer_fn in basic_rows: hint = _KV_HINTS.get(label) label_html = ( f'{_esc(label)}' if hint else _esc(label) ) cells = [f'{label_html}', f'{self_v}'] for p in peers: cells.append(f'{peer_fn(p) if peer_fn else "—"}') body_rows.append('' + ''.join(cells) + '') basic_html = ( '' '' + ''.join(header_cells) + '' '' + ''.join(body_rows) + '' '
' ) else: basic_html = '' + ''.join( _kv_row(k, v) for k, v, _ in basic_rows ) + '
' # ---- 차트 SVG (3개 기간 미리 렌더) ---- chart_1y = render_svg_chart(snap, '1Y') chart_6m = render_svg_chart(snap, '6M') chart_1m = render_svg_chart(snap, '1M') chart_block = ( '
' '' '' '' '
' f'
{chart_1y}
' f'
{chart_6m}
' f'
{chart_1m}
' ) # ---- 외국인/기관 수급 표 (최근 20일) ---- flow_rows = [] for r in snap['flow']: f_color = '#ef4444' if r['foreign'] > 0 else ('#3b82f6' if r['foreign'] < 0 else '#9ca3af') i_color = '#ef4444' if r['institution'] > 0 else ('#3b82f6' if r['institution'] < 0 else '#9ca3af') p_color = '#ef4444' if r['individual'] > 0 else ('#3b82f6' if r['individual'] < 0 else '#9ca3af') flow_rows.append( f'' f'{_esc(_fmt_date(r["date"]))}' f'{r["close"]:,}' f'{r["foreign"]*1000:+,}' f'{r["institution"]*1000:+,}' f'{r["individual"]*1000:+,}' f'' ) flow_html = ( '' '' '' + ''.join(flow_rows) + '' '
일자종가외국인기관개인
' f'

20일 누적 — ' f'0 else "#3b82f6"}">외국인 {fs["foreign_net"]*1000:+,}주 ' f'(매수일 {fs["foreign_buy_days"]}/20) · ' f'0 else "#3b82f6"}">기관 {fs["institution_net"]*1000:+,}주 ' f'(매수일 {fs["institution_buy_days"]}/20) · ' f'개인 {fs["individual_net"]*1000:+,}주

' ) # 비교업체는 기본 정보 표에 컬럼으로 통합됨 (위 basic_html 참조). # ---- 보유 정보 (있을 때) ---- holding_html = '' if holding: pp = holding.get('profit_pct', 0) pp_color = '#ef4444' if pp > 0 else ('#3b82f6' if pp < 0 else '#9ca3af') holding_html = ( '
' '

💼 내 포지션

' '' f'' f'' f'' '
평균단가{holding.get("avg_price",0):,}원
보유수량{holding.get("qty",0):,}주
수익률{pp:+.2f}%
' ) # ---- LLM 코멘트 섹션 ---- comment_html_parts = ['

✍️ 분석 코멘트

'] for s in parsed['sections']: title = _esc(s['title']) # 본문 단락 분리 — 빈 줄 기준 paras = [p.strip() for p in s['body'].split('\n\n') if p.strip()] if not paras: paras = [s['body']] body_html = ''.join(f'

{_esc(para).replace(chr(10), "
")}

' for para in paras) comment_html_parts.append( f'

{title}

{body_html}
' ) comment_html_parts.append('
') comment_html = ''.join(comment_html_parts) # ---- 투자의견 게이지 ---- verdict_color = _verdict_color(pct) verdict_html = ( '
' '

🎯 투자의견

' f'{_render_verdict_bar(pct, label, verdict.get("one_line",""))}' f'

→ {_esc(verdict.get("one_line",""))}

' '

※ 참고용 · LLM 자동 생성 · 최종 판단은 관리자님

' '
' ) # ---- 조립 ---- # 한 화면 구성: 결론(요약+투자의견)을 맨 위로, 성장성·컨센서스·포지션은 2단 그리드로 # 나란히, 폭이 필요한 기본정보·차트·수급·코멘트는 전체폭. grid_inner = ( _render_fundamentals_html(snap.get('fundamentals')) + _render_consensus_html(snap.get('consensus')) + holding_html ) grid_html = f'
{grid_inner}
' if grid_inner.strip() else '' body = ( head_meta + '
' + f'
' + f'

📑 {_esc(name)} ({_esc(code)})

' + f'

작성: {_esc(ts)}

' + '
' + (f'
📌 {_esc(oneline)}
' if oneline else '') + verdict_html + f'

📊 기본 정보

{basic_html}
' + grid_html + f'

📈 캔들 + 이평선 + 거래량

{chart_block}
' + f'

💰 외국인·기관 수급 (최근 20일, 단위: 주)

{flow_html}
' + comment_html + '
' ) return body # ============================================================================ # 보고서 생성기 — collect → llm → parse → build → save # ============================================================================ def generate_report(code: str, holding: dict | None = None) -> dict: """보고서 1개 생성 후 저장. 반환: {ok, path, error}. 동기 호출 — 보통 워커 thread에서 부르므로 caller가 시간을 안다. """ try: snap = collect_snapshot(code, holding=holding) prompt = load_prompt_template().format(data_block=_build_data_block(snap)) text = call_llm(prompt) parsed = parse_llm_output(text) html = build_report_html(snap, parsed) p = save_report(code, html) return {'ok': True, 'path': str(p), 'filename': p.name} except Exception as e: return {'ok': False, 'error': str(e)} # ============================================================================ # 큐 + 워커 # ============================================================================ _queue_locks: dict[str, threading.Lock] = {} _queue_locks_global = threading.Lock() def _code_lock(code: str) -> threading.Lock: with _queue_locks_global: lk = _queue_locks.get(code) if lk is None: lk = threading.Lock() _queue_locks[code] = lk return lk def enqueue(code: str, holding: dict | None = None) -> dict: """새 보고서 요청. 이미 running이면 무시. 새 thread로 generate_report 실행. 반환: {status: 'queued'|'already_running', queue: } """ state = read_queue(code) if state.get('status') == 'running': return {'status': 'already_running', 'queue': state} write_queue(code, {'status': 'running', 'started_at': now_kst().isoformat()}) def _worker(): lk = _code_lock(code) with lk: try: result = generate_report(code, holding=holding) if result['ok']: write_queue(code, { 'status': 'done', 'last_filename': result['filename'], 'finished_at': now_kst().isoformat(), }) else: write_queue(code, { 'status': 'failed', 'error': result.get('error', 'unknown'), 'finished_at': now_kst().isoformat(), }) except Exception as e: write_queue(code, { 'status': 'failed', 'error': f'worker exception: {e}', 'finished_at': now_kst().isoformat(), }) t = threading.Thread(target=_worker, daemon=True, name=f'stock-analysis-{code}') t.start() return {'status': 'queued', 'queue': read_queue(code)} # ============================================================================ # 종목 상세 페이지 — /stock/ 응답 HTML 전체 # ============================================================================ _PAGE_CSS = """ *{box-sizing:border-box} body{background:#0f1419;color:#cfd5df;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;margin:0;padding:0;font-size:14px;line-height:1.6} .page-wrap{width:100%;max-width:1400px;margin:0 auto;padding:14px 16px 60px} @media(max-width:640px){.page-wrap{padding:10px 12px 40px}} .page-header{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;padding:6px 0 14px;border-bottom:1px solid #2a2f3a;margin-bottom:18px} .page-header h1{font-size:22px;margin:0;color:#fff} .page-header .code{color:#7a8493;font-weight:normal;font-size:16px;margin-left:6px} .page-header .nav-row{display:flex;gap:8px;flex-wrap:wrap} .page-header a.back{color:#7a8493;text-decoration:none;padding:6px 12px;border:1px solid #2a2f3a;border-radius:6px;font-size:13px;white-space:nowrap} .page-header a.back:hover{color:#fff;border-color:#3f4654} .page-header a.back.primary{color:#fbbf24;border-color:rgba(251,191,36,0.35);background:rgba(251,191,36,0.06)} .page-header a.back.primary:hover{background:rgba(251,191,36,0.12);border-color:#fbbf24} .generate-form{margin:0 0 18px} .generate-btn{display:block;width:100%;max-width:520px;margin:0 auto;padding:14px 18px;background:#22c55e;color:#fff;border:0;border-radius:8px;cursor:pointer;font-size:15px;font-weight:700;letter-spacing:0.02em} .generate-btn:hover{background:#16a34a} .generate-btn[disabled]{background:#374151;cursor:not-allowed} .queue-banner{margin:0 0 16px;padding:12px 16px;border-radius:8px;font-size:13px} .queue-running{background:#1e3a8a;border-left:4px solid #60a5fa} .queue-failed{background:#7f1d1d;border-left:4px solid #ef4444} .error-banner{margin:0 0 16px;padding:12px 16px;border-radius:8px;background:#7f1d1d;border-left:4px solid #ef4444;color:#fee;font-size:13px;line-height:1.5} .section-title{font-size:13px;color:#7a8493;text-transform:uppercase;letter-spacing:0.05em;margin:18px 0 12px;font-weight:600} .report-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px} .report-card{display:block;padding:16px 18px;border-radius:10px;color:#cfd5df;text-decoration:none;background:#161b22;border:1px solid #1f2937;transition:transform 0.08s,border-color 0.12s} .report-card:hover{background:#1f2937;border-color:#3f4654;transform:translateY(-1px)} .report-card .ts{font-size:12px;color:#7a8493;display:block;margin-bottom:6px} .report-card .oneline{font-size:14px;font-weight:500;margin-bottom:10px;color:#fff;line-height:1.4;min-height:38px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} .report-card .pct{display:inline-block;font-size:12px;padding:3px 10px;border-radius:12px;color:#fff;font-weight:600} .empty-state{padding:60px 20px;text-align:center;color:#7a8493;background:#161b22;border-radius:10px} .empty-state h3{margin:0 0 8px;color:#cfd5df} .rpt-header h2{margin:0;font-size:22px;color:#fff} .rpt-code{color:#7a8493;font-size:16px;font-weight:normal} .rpt-ts{color:#7a8493;margin:4px 0 0;font-size:13px} .rpt-oneline{background:#1f2937;padding:14px 18px;border-radius:8px;margin:18px 0;font-size:16px;font-weight:600;border-left:4px solid #fbbf24} .rpt-section{margin:14px 0;padding:16px;background:#161b22;border-radius:8px} .rpt-section h3{margin:0 0 10px;font-size:16px;color:#fff;border-bottom:1px solid #2a2f3a;padding-bottom:8px} .rpt-cap{margin:0 0 12px;color:#9aa4b2;font-size:12px;line-height:1.6;background:#0f1419;padding:9px 12px;border-radius:6px;border-left:3px solid #3f4654} .rpt-cap b{color:#cfd5df;font-weight:600} /* 보조 섹션 2단(넓으면 다단) 그리드 — 한 화면에 모이게 */ .rpt-cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:14px;align-items:start;margin:14px 0} .rpt-cols>.rpt-section{margin:0;height:100%} .kvtable{border-collapse:collapse;width:100%} .kvtable th{text-align:left;padding:7px 10px;color:#7a8493;font-weight:500;width:160px;border-bottom:1px solid #1f2937;font-size:13px} .kvtable td{padding:7px 10px;border-bottom:1px solid #1f2937} .flowtable{border-collapse:collapse;width:100%;font-size:13px} .flowtable th{padding:6px 10px;color:#7a8493;border-bottom:1px solid #2a2f3a;text-align:left} .flowtable td{padding:5px 10px;border-bottom:1px solid #1a1f28} .flowsum{margin:10px 0 0;padding:10px;background:#0f1419;border-radius:6px;font-size:13px} .rpt-llm-block{margin:16px 0;padding:14px;background:#0f1419;border-radius:6px} .rpt-llm-block h4{margin:0 0 10px;color:#fbbf24;font-size:14px} .rpt-llm-block p{margin:8px 0;text-align:justify} .rpt-verdict{background:linear-gradient(135deg,#1f2937 0%,#161b22 100%)} .verdict-line{font-size:16px;font-weight:600;margin:10px 0 6px;color:#fff} .rpt-disclaimer{color:#7a8493;font-size:12px;margin:6px 0 0} .rpt-content{max-width:1100px;margin:0 auto} .chart-tabs{display:flex;gap:6px;margin-bottom:12px;flex-wrap:wrap} .chart-tabs button{padding:6px 14px;background:#1f2937;color:#7a8493;border:1px solid #2a2f3a;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;font-family:inherit} .chart-tabs button:hover{color:#cfd5df;border-color:#3f4654} .chart-tabs button.active{background:rgba(251,191,36,0.12);color:#fbbf24;border-color:#fbbf24} .chart-panel{display:none} .chart-panel.active{display:block} .kvtable th{vertical-align:top} .info-label{cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;border-bottom:1px dotted #5b6473;display:inline-flex;align-items:center;gap:4px;color:#7a8493;padding:1px 2px} .info-label::after{content:'ⓘ';font-size:11px;color:#5b6473;font-weight:normal} .info-label:active,.info-label.holding{color:#fbbf24;border-bottom-color:#fbbf24} .info-label:active::after,.info-label.holding::after{color:#fbbf24} .info-popup{position:fixed;left:0;top:0;max-width:280px;padding:10px 14px;background:#1f2937;color:#fff;border-radius:10px;font-size:13px;line-height:1.55;box-shadow:0 8px 28px rgba(0,0,0,0.55);border-left:3px solid #fbbf24;pointer-events:none;z-index:1500;opacity:0;visibility:hidden;transition:opacity 0.12s,transform 0.12s;transform:translateY(4px);font-weight:normal} .info-popup.show{opacity:1;visibility:visible;transform:translateY(0)} .info-popup::after{content:'';position:absolute;left:var(--arrow-x,50%);transform:translateX(-50%);border:7px solid transparent} .info-popup.above::after{bottom:-7px;border-top-color:#1f2937;border-bottom-width:0} .info-popup.below::after{top:-7px;border-bottom-color:#1f2937;border-top-width:0} .peers-manage{margin:0 0 20px;padding:16px;background:#161b22;border-radius:10px;border:1px solid #1f2937} .peers-help{margin:0 0 12px;color:#7a8493;font-size:12px;line-height:1.5} .peers-empty,.peers-full{margin:0 0 10px;color:#7a8493;font-size:13px} .peer-chips{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 12px} .peer-chip{display:inline-flex;align-items:center;gap:8px;padding:6px 6px 6px 12px;background:#1f2937;border:1px solid #2a2f3a;border-radius:20px;font-size:13px} .peer-chip a{color:#fbbf24;text-decoration:none;font-weight:600} .peer-chip a:hover{text-decoration:underline} .peer-code{color:#7a8493;font-size:11px;font-variant-numeric:tabular-nums} .peer-del{background:transparent;border:0;color:#7a8493;cursor:pointer;font-size:18px;line-height:1;padding:2px 8px;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center} .peer-del:hover{background:rgba(239,68,68,0.18);color:#ef4444} .peer-add{display:flex;gap:8px} .peer-add input{flex:1;padding:9px 12px;background:#0f1419;color:#cfd5df;border:1px solid #2a2f3a;border-radius:6px;font-size:14px;font-family:inherit;min-width:0} .peer-add input:focus{outline:0;border-color:#fbbf24} .peer-add button{padding:9px 16px;background:rgba(251,191,36,0.12);color:#fbbf24;border:1px solid rgba(251,191,36,0.35);border-radius:6px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap} .peer-add button:hover{background:rgba(251,191,36,0.2)} .peertable{border-collapse:collapse;width:100%;font-size:13px} .peertable th{padding:6px 10px;color:#7a8493;border-bottom:1px solid #2a2f3a;text-align:left;font-weight:600} .peertable th:not(:first-child){text-align:right} .peertable td{padding:7px 10px;border-bottom:1px solid #1a1f28} .peers-note{margin:10px 0 0;color:#7a8493;font-size:11px} .basicpeertable{border-collapse:collapse;width:100%;font-size:13px;table-layout:fixed} .basicpeertable th.lbl{text-align:left;padding:7px 10px;color:#7a8493;font-weight:500;width:140px;border-bottom:1px solid #1f2937;font-size:13px;vertical-align:top} .basicpeertable thead th{padding:8px 10px;color:#cfd5df;border-bottom:1px solid #2a2f3a;font-weight:600;text-align:left;background:#0f1419} .basicpeertable thead th:nth-child(2){color:#fbbf24} .basicpeertable td{padding:7px 10px;border-bottom:1px solid #1f2937;vertical-align:top;word-break:break-word} .bottom-nav{position:fixed;left:0;right:0;bottom:0;background:rgba(15,20,25,0.94);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border-top:1px solid #2a2f3a;padding:10px 14px;display:flex;justify-content:center;gap:10px;z-index:100} .bottom-nav a{flex:1;max-width:260px;text-align:center;color:#7a8493;text-decoration:none;padding:11px 14px;border:1px solid #2a2f3a;border-radius:10px;font-size:14px;font-weight:600;white-space:nowrap;background:#161b22} .bottom-nav a:hover{color:#fff;border-color:#3f4654} .bottom-nav a.primary{color:#fbbf24;border-color:rgba(251,191,36,0.35);background:rgba(251,191,36,0.08)} .bottom-nav a.primary:hover{background:rgba(251,191,36,0.16);border-color:#fbbf24} body.has-bottom-nav .page-wrap{padding-bottom:96px} @supports(padding:env(safe-area-inset-bottom)){.bottom-nav{padding-bottom:calc(10px + env(safe-area-inset-bottom))}body.has-bottom-nav .page-wrap{padding-bottom:calc(96px + env(safe-area-inset-bottom))}} """ _CHART_TOGGLE_JS = """ document.querySelectorAll('.chart-tabs').forEach(function(tabs){ tabs.addEventListener('click', function(e){ var btn = e.target.closest('button[data-range]'); if (!btn) return; var range = btn.dataset.range; var scope = tabs.parentElement; if (!scope) return; scope.querySelectorAll('.chart-tabs button').forEach(function(b){ b.classList.toggle('active', b === btn); }); scope.querySelectorAll('.chart-panel').forEach(function(p){ p.classList.toggle('active', p.dataset.range === range); }); }); }); """ # 누름·뗌(press-and-hold) 팝업 — 라벨 위 또는 아래에 떠서 손가락 가리지 않게. # 떼면 사라짐. 모바일 long-press 콜아웃·텍스트선택은 user-select:none·preventDefault로 차단. _INFO_POPUP_JS = """ (function(){ var popup = document.createElement('div'); popup.className = 'info-popup'; document.body.appendChild(popup); var current = null; function position(el){ var rect = el.getBoundingClientRect(); var pop = popup.getBoundingClientRect(); var vw = window.innerWidth, vh = window.innerHeight; var left = rect.left + rect.width/2 - pop.width/2; var marg = 8; if (left < marg) left = marg; if (left + pop.width > vw - marg) left = vw - marg - pop.width; var labelCenter = rect.left + rect.width/2; var arrowX = labelCenter - left; if (arrowX < 14) arrowX = 14; if (arrowX > pop.width - 14) arrowX = pop.width - 14; popup.style.setProperty('--arrow-x', arrowX + 'px'); var top = rect.top - pop.height - 14; if (top < marg) { top = rect.bottom + 14; popup.classList.remove('above'); popup.classList.add('below'); } else { popup.classList.remove('below'); popup.classList.add('above'); } if (top + pop.height > vh - marg) top = vh - marg - pop.height; popup.style.left = left + 'px'; popup.style.top = top + 'px'; } function show(el){ if (current === el) return; current = el; el.classList.add('holding'); popup.textContent = el.dataset.info || ''; // 측정 위해 일단 보이게 popup.classList.add('show'); // 다음 프레임에 위치 (텍스트 후 크기 확정) requestAnimationFrame(function(){ position(el); }); } function hide(){ if (!current) return; current.classList.remove('holding'); current = null; popup.classList.remove('show'); } document.addEventListener('pointerdown', function(e){ var el = e.target.closest('.info-label'); if (!el) return; e.preventDefault(); try { el.setPointerCapture(e.pointerId); } catch(_) {} show(el); var release = function(){ hide(); window.removeEventListener('pointerup', release); window.removeEventListener('pointercancel', release); try { el.releasePointerCapture(e.pointerId); } catch(_) {} }; window.addEventListener('pointerup', release); window.addEventListener('pointercancel', release); }, {passive: false}); // 스크롤 중 떼지 못한 케이스: 스크롤 시작하면 숨김 window.addEventListener('scroll', hide, {passive: true}); })(); """ def _running_meta_refresh(queue_state: dict) -> str: """running 상태일 때 페이지 자동 새로고침 meta tag.""" if queue_state.get('status') == 'running': return '' return '' def _resolve_stock_name(code: str, reports: list[dict]) -> str: """종목명 — 보고서 메타에서 우선, 없으면 키움 단발 호출.""" if reports: try: body = read_report(code, reports[0]['filename']) or '' import re m = re.search(r'

[^<]*?([\w가-힣\s]+)<', body) if m: return m.group(1).strip().lstrip('📑').strip() except Exception: pass try: q = kc.get_stock_quote(code, exchange='KRX') return q.get('name') or '' except Exception: return '' def _queue_banner_html(queue_state: dict) -> str: """큐 상태 배너. running·failed일 때만 출력.""" status = queue_state.get('status') if status == 'running': started = queue_state.get('started_at', '') return ( '
' f'⏳ 새 보고서 생성 중입니다... (시작: {_esc(started[11:19] if started else "")}, ' '약 30~60초 소요. 6초마다 자동 새로고침)' '
' ) if status == 'failed': err = queue_state.get('error', '') return ( '
' f'❌ 직전 생성 실패: {_esc(err[:200])}' '
' ) return '' def render_stock_page(code: str, name: str = '', selected_filename: str | None = None, holding: dict | None = None, error_msg: str | None = None) -> str: """종목 상세 페이지 라우터. - selected_filename 있음 → 보고서 본문 화면 - 없음 → 보고서 선택 화면 (카드 그리드) - error_msg: POST 후 redirect로 전달된 에러 메시지 (배너로 표시) """ clean_code = ''.join(ch for ch in str(code) if ch.isalnum()) reports = list_reports(clean_code) queue_state = read_queue(clean_code) if not name: name = _resolve_stock_name(clean_code, reports) if selected_filename: return _render_report_view(clean_code, name, selected_filename, reports, queue_state) return _render_report_list(clean_code, name, reports, queue_state, error_msg=error_msg) def _render_report_list(code: str, name: str, reports: list[dict], queue_state: dict, error_msg: str | None = None) -> str: """1단계 — 보고서 선택 화면.""" refresh_meta = _running_meta_refresh(queue_state) is_running = queue_state.get('status') == 'running' # 카드 그리드 cards: list[str] = [] for r in reports: pct_html = '' if r['pct'] is not None: pct_html = ( f'' f'{r["pct"]}% · {_esc(r["label"] or "")}' ) ts_disp = _fmt_card_ts(r['ts']) cards.append( f'' f'{_esc(ts_disp)}' f'{_esc(r["oneline"] or "(요약 없음)")}' f'{pct_html}' '' ) if cards: list_html = ( f'

📚 과거 보고서 ({len(reports)}개)

' f'
{"".join(cards)}
' ) else: list_html = ( '
' '

아직 보고서가 없어요

' '

위의 📝 새 보고서 만들기 버튼을 눌러 첫 보고서를 생성하세요. 30~60초 정도 걸려요.

' '
' ) generate_form = ( f'
' + ( '' if is_running else '' ) + '
' ) # ---- 비교업체 관리 ---- peers_now = read_peers(code) peer_chips: list[str] = [] for p in peers_now: peer_chips.append( '' f'{_esc(p["name"])}' f'{_esc(p["code"])}' f'
' f'' '' '
' '
' ) can_add = len(peers_now) < PEERS_MAX peers_section = ( '
' '

🔗 비교업체 ({0}/{1})

'.format(len(peers_now), PEERS_MAX) + '

여기에 추가한 종목은 새 보고서를 만들 때마다 항상 비교 표·LLM 코멘트에 포함됩니다.

' + (f'
{"".join(peer_chips)}
' if peer_chips else '

아직 등록된 비교업체가 없어요.

') + ( f'
' '' '' '
' if can_add else f'

최대 {PEERS_MAX}개까지 등록할 수 있어요. 먼저 하나 빼주세요.

' ) + '
' ) return ( '' '' f'{_esc(name or code)} 분석 — Behive' '' f'{refresh_meta}' f'' '' '
' '' + (f'
⚠️ {_esc(error_msg)}
' if error_msg else '') + _queue_banner_html(queue_state) + generate_form + peers_section + list_html + '
' ) def _render_report_view(code: str, name: str, selected_filename: str, reports: list[dict], queue_state: dict) -> str: """2단계 — 선택된 보고서 본문 화면.""" target = next((r for r in reports if r['filename'] == selected_filename), None) if not target: # 잘못된 파일명 → 목록으로 fallback return _render_report_list(code, name, reports, queue_state) body = read_report(code, target['filename']) or '(보고서 파일 읽기 실패)' refresh_meta = _running_meta_refresh(queue_state) return ( '' '' f'{_esc(name or code)} 보고서 — Behive' '' f'{refresh_meta}' f'' '' '
' '' + _queue_banner_html(queue_state) + f'
{body}
' + '
' + '' + f'' + '' )