fed3526b20
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2122 lines
92 KiB
Python
2122 lines
92 KiB
Python
#!/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('&', '&').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'<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>'
|
||
)
|