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

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

2122 lines
92 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""종목분석 보고서 — 데이터 수집·SMA·SVG 차트·LLM 코멘트·저장소·큐 워커.
behive_web에서 /stock/<code> 라우트가 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}.
첫 줄 주석에서 메타 파싱: <!-- oneline:... --> / <!-- recommendation: 45% / 중립 -->
"""
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줄에서 <!-- key: value --> 주석 파싱."""
out: dict = {}
try:
head = p.read_text().splitlines()[:6]
except Exception:
return out
for line in head:
line = line.strip()
if not (line.startswith('<!--') and line.endswith('-->')):
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 '<div style="padding:24px;color:#888">차트 데이터 없음</div>'
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'<svg viewBox="0 0 {CHART_W} {CHART_H}" '
f'xmlns="http://www.w3.org/2000/svg" '
f'style="width:100%;height:auto;font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,sans-serif;font-size:14px;background:#0f1419;touch-action:none;" '
f'data-meta="{meta_attr}">'
)
# ---- 가격 패널 배경 + 격자 ----
parts.append(
f'<rect x="{CHART_PAD_L}" y="{PRICE_TOP}" '
f'width="{plot_w}" height="{PRICE_PANEL_H}" fill="#0a0d12" stroke="#2a2f3a"/>'
)
# 가격 가로 격자 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'<line x1="{CHART_PAD_L}" y1="{y:.1f}" x2="{CHART_W-CHART_PAD_R}" y2="{y:.1f}" '
f'stroke="#1d222b" stroke-dasharray="2 3"/>'
)
parts.append(
f'<text x="{CHART_W - CHART_PAD_R + 6}" y="{y+10:.1f}" text-anchor="start" fill="#7a8493" font-size="28">'
f'{int(round(price)):,}</text>'
)
# ---- 캔들 ----
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'<line x1="{x:.2f}" y1="{price_y(h):.2f}" x2="{x:.2f}" y2="{price_y(l):.2f}" '
f'stroke="{color}" stroke-width="1"/>'
)
# 몸통
top = price_y(max(o, cl))
bot = price_y(min(o, cl))
height = max(0.8, bot - top)
parts.append(
f'<rect x="{x-body_w/2:.2f}" y="{top:.2f}" width="{body_w:.2f}" height="{height:.2f}" '
f'fill="{color}" stroke="{color}"/>'
)
# ---- 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'<path d="{d}" fill="none" stroke="{color}" stroke-width="1.4" opacity="0.95"/>'
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'<rect x="{lx}" y="{legend_y-8}" width="10" height="3" fill="{color}"/>'
)
parts.append(
f'<text x="{lx+14}" y="{legend_y-2}" fill="#cfd5df">{name}</text>'
)
lx += 70
# ---- 거래량 패널 배경 ----
parts.append(
f'<rect x="{CHART_PAD_L}" y="{VOL_TOP}" '
f'width="{plot_w}" height="{VOL_PANEL_H}" fill="#0a0d12" stroke="#2a2f3a"/>'
)
# 거래량 막대
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'<rect x="{x-body_w/2:.2f}" y="{bar_top:.2f}" width="{body_w:.2f}" height="{bar_h:.2f}" '
f'fill="{color}" opacity="0.7"/>'
)
# 거래량 Y 레이블 (max 만) — 우측
parts.append(
f'<text x="{CHART_W - CHART_PAD_R + 6}" y="{VOL_TOP+22}" text-anchor="start" fill="#7a8493" font-size="28">'
f'{_fmt_vol(v_max)}</text>'
)
# ---- 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'<text x="{x:.1f}" y="{VOL_BOT+26}" text-anchor="{anchor}" fill="#7a8493" font-size="28">'
f'{_fmt_date(candles[i]["date"])}</text>'
)
parts.append('</svg>')
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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
.replace('"', '&quot;').replace("'", '&#39;'))
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'<span class="info-label" data-info="{_esc(hint)}">{_esc(label)}</span>'
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'<span class="info-label" data-info="{_esc(hint)}">{_esc(label)}</span>'
)
else:
label_html = _esc(label)
return f'<tr><th>{label_html}</th><td>{value_html}</td></tr>'
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'<line x1="{x}" y1="0" x2="{x}" y2="{bar_h}" stroke="#0f1419" stroke-width="1"/>'
)
svg = (
f'<svg viewBox="0 0 {bar_w} {bar_h+22}" xmlns="http://www.w3.org/2000/svg" '
f'style="width:100%;max-width:600px;height:auto;display:block;margin:6px 0;">'
f'<rect x="0" y="0" width="{bar_w}" height="{bar_h}" fill="#1f2937" rx="4"/>'
f'<rect x="0" y="0" width="{fill_w}" height="{bar_h}" fill="{color}" rx="4"/>'
+ ''.join(boundaries)
+ f'<text x="{bar_w/2}" y="{bar_h/2+5}" text-anchor="middle" fill="#ffffff" '
f'font-weight="bold" font-size="14">{pct}% · {_esc(label)}</text>'
+ f'<text x="0" y="{bar_h+16}" fill="#7a8493" font-size="10">0 추천 안 함</text>'
+ f'<text x="{bar_w}" y="{bar_h+16}" text-anchor="end" fill="#7a8493" font-size="10">100 강력 추천</text>'
+ '</svg>'
)
return svg
def _fmt_growth(v) -> str:
"""증가율(%) → 색상 포함 셀. 빨강=증가/파랑=감소 (국내 관행)."""
if not isinstance(v, (int, float)):
return '<span style="color:#7a8493">—</span>'
color = '#ef4444' if v > 0 else ('#3b82f6' if v < 0 else '#9ca3af')
return f'<span style="color:{color};font-weight:700">{v:+.1f}%</span>'
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(
'<tr>'
f'<td>{_esc(r.get("period",""))}</td>'
f'<td style="text-align:right">{_fmt_num(round(sales)) if isinstance(sales,(int,float)) else ""}</td>'
f'<td style="text-align:right">{_fmt_growth(r.get("sales_growth"))}</td>'
f'<td style="text-align:right">{_fmt_num(round(oper)) if isinstance(oper,(int,float)) else ""}</td>'
f'<td style="text-align:right">{_fmt_num(round(eps)) if isinstance(eps,(int,float)) else ""}</td>'
f'<td style="text-align:right">{_fmt_growth(r.get("eps_growth"))}</td>'
f'<td style="text-align:right">{(str(roe)+"%") if isinstance(roe,(int,float)) else ""}</td>'
'</tr>'
)
table_html = (
'<table class="flowtable">'
'<thead><tr>'
f'<th>기간</th><th>{_lbl("매출액(억)")}</th><th>{_lbl("매출증가율")}</th>'
f'<th>{_lbl("영업이익(억)")}</th><th>{_lbl("EPS(원)")}</th>'
f'<th>{_lbl("EPS증가율")}</th><th>{_lbl("ROE")}</th>'
'</tr></thead>'
'<tbody>' + ''.join(body) + '</tbody></table>'
)
# ---- 컨센서스 카드 ----
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'<tr><th>{_lbl("목표주가")}</th><td style="font-weight:700;color:#fbbf24">{_fmt_num(round(tp))}원</td></tr>')
if op_label:
parts.append(f'<tr><th>{_lbl("투자의견")}</th><td>{_esc(op_label)} '
f'<span style="color:#7a8493;font-size:11px">({op}/5)</span></td></tr>')
if isinstance(ce, (int, float)):
parts.append(f'<tr><th>{_lbl("추정 EPS")}</th><td>{_fmt_num(round(ce))}원</td></tr>')
if isinstance(cper, (int, float)):
parts.append(f'<tr><th>{_lbl("추정 PER")}</th><td>{cper}배</td></tr>')
if isinstance(oc, (int, float)):
parts.append(f'<tr><th>{_lbl("참여 기관수")}</th><td>{int(oc)}개</td></tr>')
if parts:
cdate = consensus.get('date')
date_html = (f'<span style="color:#7a8493;font-size:11px"> · 기준 {_esc(cdate)}</span>'
if cdate else '')
cons_html = (
f'<h4 style="margin:10px 0 4px">📋 컨센서스{date_html}</h4>'
'<table class="kvtable">' + ''.join(parts) + '</table>'
)
if not table_html and not cons_html:
return ''
note = ('<p style="color:#7a8493;font-size:11px;margin-top:6px">'
'출처: FnGuide 컴퍼니가이드 · 증가율은 전년대비(YoY)</p>')
return (
'<section class="rpt-section">'
'<h3>📈 성장성 · 컨센서스</h3>'
'<p class="rpt-cap">회사가 해마다 얼마나 더 벌고 있는지(성장성)와, 증권사들이 본 적정 주가·이익 전망(컨센서스)이에요. '
'밑줄 친 항목 이름(<b>ⓘ</b>)을 누르면 쉬운 설명이 떠요.</p>'
+ table_html + cons_html + note +
'</section>'
)
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'<b>{_fmt_num(round(rev["target_last"]))}원</b> {_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'<b>{_fmt_num(round(rev["est_last"]))}</b> {_fmt_growth(echg)}')
if bits:
n = rev.get('n_points')
parts.append(
'<h4 style="margin:10px 0 4px">🔁 목표주가·추정치 리비전 '
f'<span style="color:#7a8493;font-size:11px">(최근 {n}주)</span></h4>'
'<p style="margin:2px 0">' + ' · '.join(bits) + '</p>'
)
# ---- 어닝 서프라이즈 ----
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(
'<tr>'
f'<td>{_esc(label)}</td>'
f'<td style="text-align:right">{_fmt_num(round(act)) if isinstance(act,(int,float)) else ""}</td>'
f'<td style="text-align:right">{_fmt_growth(sp)}</td>'
'</tr>'
)
if sup_rows:
any_year = next((v.get('fy0_year') for v in items.values() if v.get('fy0_year')), '')
parts.append(
f'<h4 style="margin:10px 0 4px">🎯 어닝 서프라이즈 '
f'<span style="color:#7a8493;font-size:11px">({_esc(any_year)} 실적 vs 직전 컨센서스)</span></h4>'
'<table class="flowtable">'
f'<thead><tr><th>항목</th><th>{_lbl("실적(억)")}</th><th>{_lbl("서프라이즈")}</th></tr></thead>'
'<tbody>' + ''.join(sup_rows) + '</tbody></table>'
)
# ---- 연도별 추정 재무 (A 실적 + E 추정) ----
if annual:
body = []
for r in annual:
est = r.get('is_estimate')
tag = ('<span style="color:#fbbf24;font-size:11px"> (E)</span>' if est
else '<span style="color:#7a8493;font-size:11px"> (A)</span>')
sales = r.get('sales'); op = r.get('op'); eps = r.get('eps'); roe = r.get('roe')
body.append(
'<tr' + (' style="opacity:.85"' if est else '') + '>'
f'<td>{_esc(r.get("period",""))}{tag}</td>'
f'<td style="text-align:right">{_fmt_num(round(sales)) if isinstance(sales,(int,float)) else ""}</td>'
f'<td style="text-align:right">{_fmt_growth(r.get("sales_yoy"))}</td>'
f'<td style="text-align:right">{_fmt_num(round(op)) if isinstance(op,(int,float)) else ""}</td>'
f'<td style="text-align:right">{_fmt_num(round(eps)) if isinstance(eps,(int,float)) else ""}</td>'
f'<td style="text-align:right">{(str(roe)+"%") if isinstance(roe,(int,float)) else ""}</td>'
'</tr>'
)
parts.append(
'<h4 style="margin:10px 0 4px">📅 연도별 추정 재무 '
'<span style="color:#7a8493;font-size:11px">(IFRS연결 · A 실적 / E 추정)</span></h4>'
'<table class="flowtable">'
f'<thead><tr><th>기간</th><th>{_lbl("매출액(억)")}</th><th>{_lbl("YoY")}</th>'
f'<th>{_lbl("영업이익(억)")}</th><th>{_lbl("EPS(원)")}</th><th>{_lbl("ROE")}</th></tr></thead>'
'<tbody>' + ''.join(body) + '</tbody></table>'
)
if not parts:
return ''
note = ('<p style="color:#7a8493;font-size:11px;margin-top:6px">'
'출처: FnGuide WISEreport 컨센서스(증권사 추정 3개월 평균). '
'추정 절대값은 데이터원 원본이며 실제 스케일과 다를 수 있습니다(방향성·괴리율 위주로 해석).</p>')
return (
'<section class="rpt-section">'
'<h3>🔮 컨센서스 (추정·리비전·서프라이즈)</h3>'
'<p class="rpt-cap">증권사들이 본 <b>미래 실적 전망</b>이에요. '
'<b>리비전</b>=그 전망이 최근 3개월간 좋아지는지(+)/나빠지는지(−), '
'<b>서프라이즈</b>=지난 실적이 예상보다 잘 나왔는지(+)/못 나왔는지(−), '
'<b>연도별 추정</b>=해마다 얼마 벌지 예상치(E)와 실제(A). 밑줄 항목(<b>ⓘ</b>)을 누르면 설명이 떠요.</p>'
+ ''.join(parts) + note +
'</section>'
)
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'<!-- oneline: {_esc(oneline)} -->\n'
f'<!-- recommendation: {pct}% / {_esc(label)} -->\n'
f'<!-- generated_at: {snap.get("snapshot_at","")} -->\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 '<span style="color:#ef4444;font-size:11px">조회 실패</span>'
pc = '#ef4444' if p['change'] > 0 else ('#3b82f6' if p['change'] < 0 else '#9ca3af')
return (f'<span style="color:{pc};font-weight:700">{p["price"]:,}원</span> '
f'<span style="color:{pc};font-size:11px">({_fmt_pct_signed(p["change_pct"])})</span>')
def _peer_simple(p: dict, field: str, suffix: str) -> str:
if p.get('error'):
return '<span style="color:#ef4444;font-size:11px">조회 실패</span>'
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 '<span style="color:#ef4444;font-size:11px">조회 실패</span>'
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 '<span style="color:#ef4444;font-size:11px">조회 실패</span>'
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 '<span style="color:#ef4444;font-size:11px">조회 실패</span>'
v = p.get(value_field)
if not isinstance(v, (int, float)):
return ''
dt = p.get(date_field)
date_html = f' <span style="color:#7a8493;font-size:11px">({_fmt_date(dt)})</span>' 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'<span style="color:{change_color};font-weight:700">{b["price"]:,}원</span> '
f'<span style="color:{change_color}">({b["change"]:+,} / {_fmt_pct_signed(b["change_pct"])})</span>',
_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"]:,} <span style="color:#7a8493;font-size:11px">(키움 원본 단위)</span>',
lambda p: _peer_int(p, 'market_cap', '')),
('상장주식수', f'{b["listed_shares"]:,}', lambda p: _peer_int(p, 'listed_shares', '')),
('52주 최고', f'{b["high_52w"]:,}원 <span style="color:#7a8493;font-size:11px">({_fmt_date(b["high_52w_dt"])})</span>',
lambda p: _peer_52w(p, 'high_52w', 'high_52w_dt')),
('52주 최저', f'{b["low_52w"]:,}원 <span style="color:#7a8493;font-size:11px">({_fmt_date(b["low_52w_dt"])})</span>',
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 = [
'<th></th>',
f'<th>{_esc(name)} <span style="color:#fbbf24;font-size:11px">[본 종목]</span></th>',
]
for p in peers:
pname = _esc(p['name'])
pcode = _esc(p['code'])
header_cells.append(f'<th>{pname} <span style="color:#7a8493;font-size:11px">({pcode})</span></th>')
body_rows: list[str] = []
for label, self_v, peer_fn in basic_rows:
hint = _KV_HINTS.get(label)
label_html = (
f'<span class="info-label" data-info="{_esc(hint)}">{_esc(label)}</span>'
if hint else _esc(label)
)
cells = [f'<th class="lbl">{label_html}</th>', f'<td>{self_v}</td>']
for p in peers:
cells.append(f'<td>{peer_fn(p) if peer_fn else ""}</td>')
body_rows.append('<tr>' + ''.join(cells) + '</tr>')
basic_html = (
'<table class="basicpeertable">'
'<thead><tr>' + ''.join(header_cells) + '</tr></thead>'
'<tbody>' + ''.join(body_rows) + '</tbody>'
'</table>'
)
else:
basic_html = '<table class="kvtable">' + ''.join(
_kv_row(k, v) for k, v, _ in basic_rows
) + '</table>'
# ---- 차트 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 = (
'<div class="chart-tabs">'
'<button type="button" data-range="1Y" class="active">1년</button>'
'<button type="button" data-range="6M">6개월</button>'
'<button type="button" data-range="1M">1개월</button>'
'</div>'
f'<div class="chart-panel active" data-range="1Y">{chart_1y}</div>'
f'<div class="chart-panel" data-range="6M">{chart_6m}</div>'
f'<div class="chart-panel" data-range="1M">{chart_1m}</div>'
)
# ---- 외국인/기관 수급 표 (최근 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'<tr>'
f'<td>{_esc(_fmt_date(r["date"]))}</td>'
f'<td style="text-align:right">{r["close"]:,}</td>'
f'<td style="text-align:right;color:{f_color}">{r["foreign"]*1000:+,}</td>'
f'<td style="text-align:right;color:{i_color}">{r["institution"]*1000:+,}</td>'
f'<td style="text-align:right;color:{p_color}">{r["individual"]*1000:+,}</td>'
f'</tr>'
)
flow_html = (
'<table class="flowtable">'
'<thead><tr><th>일자</th><th>종가</th><th>외국인</th><th>기관</th><th>개인</th></tr></thead>'
'<tbody>' + ''.join(flow_rows) + '</tbody>'
'</table>'
f'<p class="flowsum">20일 누적 — '
f'<b style="color:{"#ef4444" if fs["foreign_net"]>0 else "#3b82f6"}">외국인 {fs["foreign_net"]*1000:+,}주</b> '
f'(매수일 {fs["foreign_buy_days"]}/20) · '
f'<b style="color:{"#ef4444" if fs["institution_net"]>0 else "#3b82f6"}">기관 {fs["institution_net"]*1000:+,}주</b> '
f'(매수일 {fs["institution_buy_days"]}/20) · '
f'개인 {fs["individual_net"]*1000:+,}주</p>'
)
# 비교업체는 기본 정보 표에 컬럼으로 통합됨 (위 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 = (
'<section class="rpt-section">'
'<h3>💼 내 포지션</h3>'
'<table class="kvtable">'
f'<tr><th>평균단가</th><td>{holding.get("avg_price",0):,}원</td></tr>'
f'<tr><th>보유수량</th><td>{holding.get("qty",0):,}주</td></tr>'
f'<tr><th>수익률</th><td style="color:{pp_color};font-weight:700">{pp:+.2f}%</td></tr>'
'</table></section>'
)
# ---- LLM 코멘트 섹션 ----
comment_html_parts = ['<section class="rpt-section"><h3>✍️ 분석 코멘트</h3>']
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'<p>{_esc(para).replace(chr(10), "<br>")}</p>' for para in paras)
comment_html_parts.append(
f'<div class="rpt-llm-block"><h4>{title}</h4>{body_html}</div>'
)
comment_html_parts.append('</section>')
comment_html = ''.join(comment_html_parts)
# ---- 투자의견 게이지 ----
verdict_color = _verdict_color(pct)
verdict_html = (
'<section class="rpt-section rpt-verdict">'
'<h3>🎯 투자의견</h3>'
f'{_render_verdict_bar(pct, label, verdict.get("one_line",""))}'
f'<p class="verdict-line">→ {_esc(verdict.get("one_line",""))}</p>'
'<p class="rpt-disclaimer">※ 참고용 · LLM 자동 생성 · 최종 판단은 관리자님</p>'
'</section>'
)
# ---- 조립 ----
# 한 화면 구성: 결론(요약+투자의견)을 맨 위로, 성장성·컨센서스·포지션은 2단 그리드로
# 나란히, 폭이 필요한 기본정보·차트·수급·코멘트는 전체폭.
grid_inner = (
_render_fundamentals_html(snap.get('fundamentals'))
+ _render_consensus_html(snap.get('consensus'))
+ holding_html
)
grid_html = f'<div class="rpt-cols">{grid_inner}</div>' if grid_inner.strip() else ''
body = (
head_meta
+ '<article class="rpt">'
+ f'<header class="rpt-header">'
+ f'<h2>📑 {_esc(name)} <span class="rpt-code">({_esc(code)})</span></h2>'
+ f'<p class="rpt-ts">작성: {_esc(ts)}</p>'
+ '</header>'
+ (f'<div class="rpt-oneline">📌 {_esc(oneline)}</div>' if oneline else '')
+ verdict_html
+ f'<section class="rpt-section"><h3>📊 기본 정보</h3>{basic_html}</section>'
+ grid_html
+ f'<section class="rpt-section"><h3>📈 캔들 + 이평선 + 거래량</h3>{chart_block}</section>'
+ f'<section class="rpt-section"><h3>💰 외국인·기관 수급 (최근 20일, 단위: 주)</h3>{flow_html}</section>'
+ comment_html
+ '</article>'
)
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: <queue dict>}
"""
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/<code> 응답 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 '<meta http-equiv="refresh" content="6">'
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'<h2>[^<]*?([\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 (
'<div class="queue-banner queue-running">'
f'⏳ 새 보고서 생성 중입니다... (시작: {_esc(started[11:19] if started else "")}, '
'약 30~60초 소요. 6초마다 자동 새로고침)'
'</div>'
)
if status == 'failed':
err = queue_state.get('error', '')
return (
'<div class="queue-banner queue-failed">'
f'❌ 직전 생성 실패: {_esc(err[:200])}'
'</div>'
)
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'<span class="pct" style="background:{_verdict_color(r["pct"])}">'
f'{r["pct"]}% · {_esc(r["label"] or "")}</span>'
)
ts_disp = _fmt_card_ts(r['ts'])
cards.append(
f'<a class="report-card" href="/stock/{_esc(code)}?r={_esc(r["filename"])}">'
f'<span class="ts">{_esc(ts_disp)}</span>'
f'<span class="oneline">{_esc(r["oneline"] or "(요약 없음)")}</span>'
f'{pct_html}'
'</a>'
)
if cards:
list_html = (
f'<h2 class="section-title">📚 과거 보고서 ({len(reports)}개)</h2>'
f'<div class="report-grid">{"".join(cards)}</div>'
)
else:
list_html = (
'<div class="empty-state">'
'<h3>아직 보고서가 없어요</h3>'
'<p>위의 <b>📝 새 보고서 만들기</b> 버튼을 눌러 첫 보고서를 생성하세요. 30~60초 정도 걸려요.</p>'
'</div>'
)
generate_form = (
f'<form class="generate-form" method="POST" action="/stock/{_esc(code)}/generate">'
+ (
'<button type="submit" class="generate-btn" disabled>🔄 생성 중...</button>'
if is_running
else '<button type="submit" class="generate-btn">📝 새 보고서 만들기</button>'
)
+ '</form>'
)
# ---- 비교업체 관리 ----
peers_now = read_peers(code)
peer_chips: list[str] = []
for p in peers_now:
peer_chips.append(
'<span class="peer-chip">'
f'<a href="/stock/{_esc(p["code"])}">{_esc(p["name"])}</a>'
f'<span class="peer-code">{_esc(p["code"])}</span>'
f'<form method="POST" action="/stock/{_esc(code)}/peers/delete" style="display:inline">'
f'<input type="hidden" name="peer_code" value="{_esc(p["code"])}">'
'<button type="submit" title="삭제" class="peer-del">×</button>'
'</form>'
'</span>'
)
can_add = len(peers_now) < PEERS_MAX
peers_section = (
'<section class="peers-manage">'
'<h2 class="section-title">🔗 비교업체 ({0}/{1})</h2>'.format(len(peers_now), PEERS_MAX)
+ '<p class="peers-help">여기에 추가한 종목은 새 보고서를 만들 때마다 항상 비교 표·LLM 코멘트에 포함됩니다.</p>'
+ (f'<div class="peer-chips">{"".join(peer_chips)}</div>' if peer_chips else '<p class="peers-empty">아직 등록된 비교업체가 없어요.</p>')
+ (
f'<form class="peer-add" method="POST" action="/stock/{_esc(code)}/peers/add">'
'<input type="text" name="stock" placeholder="종목명 또는 6자리 코드 (예: SK하이닉스, 000660)" autocomplete="off" required>'
'<button type="submit">+ 추가</button>'
'</form>'
if can_add
else f'<p class="peers-full">최대 {PEERS_MAX}개까지 등록할 수 있어요. 먼저 하나 빼주세요.</p>'
)
+ '</section>'
)
return (
'<!doctype html><html lang="ko"><head>'
'<meta charset="utf-8">'
f'<title>{_esc(name or code)} 분석 — Behive</title>'
'<meta name="viewport" content="width=device-width,initial-scale=1">'
f'{refresh_meta}'
f'<style>{_PAGE_CSS}</style>'
'</head><body>'
'<div class="page-wrap">'
'<div class="page-header">'
f'<h1>📑 {_esc(name or "(이름 미상)")} <span class="code">({_esc(code)})</span></h1>'
'<div class="nav-row">'
'<a href="/" class="back">← 워치리스트로</a>'
'</div></div>'
+ (f'<div class="error-banner">⚠️ {_esc(error_msg)}</div>' if error_msg else '')
+ _queue_banner_html(queue_state)
+ generate_form
+ peers_section
+ list_html
+ '</div></body></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 (
'<!doctype html><html lang="ko"><head>'
'<meta charset="utf-8">'
f'<title>{_esc(name or code)} 보고서 — Behive</title>'
'<meta name="viewport" content="width=device-width,initial-scale=1">'
f'{refresh_meta}'
f'<style>{_PAGE_CSS}</style>'
'</head><body class="has-bottom-nav">'
'<div class="page-wrap">'
'<div class="page-header">'
f'<h1>📑 {_esc(name or "(이름 미상)")} <span class="code">({_esc(code)})</span></h1>'
'</div>'
+ _queue_banner_html(queue_state)
+ f'<div class="rpt-content">{body}</div>'
+ '</div>'
+ '<nav class="bottom-nav">'
+ f'<a href="/stock/{_esc(code)}" class="primary">← 보고서 목록</a>'
+ '<a href="/">← 워치리스트로</a>'
+ '</nav>'
+ f'<script>{_CHART_TOGGLE_JS}{_INFO_POPUP_JS}</script>'
+ '</body></html>'
)