#!/usr/bin/env python3 """FnGuide 컴퍼니가이드 펀더멘털 조회 (조회 전용, 캐시 우선). 키움 REST에 없는 데이터를 보강한다: - 연간 재무 시계열 (매출액·영업이익·순이익·EPS·ROE·PER) → 매출액/EPS/영업이익 증가율 계산 - 컨센서스 (목표주가·투자의견·추정EPS·추정PER·추정 참여기관수) 데이터원: https://comp.fnguide.com/SVO2/xml/Snapshot_all/{code}.xml (EUC-KR XML) FnGuide HTML은 이 XML을 JS로 렌더하므로 XML을 직접 받아 파싱한다. ⚠️ FnGuide 콘텐츠는 저작권 대상. 무단 대량수집·DB구축 금지. → 종목별 파일 캐시(기본 12h) + 보유·관심 종목 on-demand 조회로만 사용한다. 호출부는 절대 예외에 의존하지 않는다 — 실패 시 None 반환(분석은 FnGuide 없이도 진행). CLI (수동 확인용): python3 fnguide_client.py # 펀더멘털 출력 python3 fnguide_client.py --fresh # 캐시 무시 재조회 """ from __future__ import annotations import json import sys import time import urllib.request import xml.etree.ElementTree as ET from pathlib import Path _SCRIPT_DIR = Path(__file__).resolve().parent WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace') CACHE_DIR = WORKSPACE / 'state' / 'fnguide_cache' CACHE_TTL_SEC = 12 * 3600 # 펀더멘털은 분기 단위 갱신 → 12h 캐시면 충분 SNAPSHOT_URL = 'https://comp.fnguide.com/SVO2/xml/Snapshot_all/{code6}.xml' _UA = ('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36') _TIMEOUT = 8 # financial_highlight field(컬럼)명 prefix → 표준 키 _FIELD_MAP = { '매출액': 'sales', '영업이익(억원)': 'oper_profit', '당기순이익': 'net_profit', '부채비율': 'debt_ratio', '영업이익률': 'op_margin', '순이익률': 'net_margin', 'ROA': 'roa', 'ROE': 'roe', 'EPS': 'eps', 'BPS': 'bps', 'PER': 'per', 'PBR': 'pbr', '배당수익률': 'div_yield', } def _norm_code(code: str) -> str: """'A005930' / '005930' / 005930 → '005930' (6자리 숫자).""" s = ''.join(ch for ch in str(code) if ch.isdigit()) return s.zfill(6)[-6:] if s else '' def _num(s: str | None) -> float | None: if s is None: return None t = s.strip().replace(',', '') if not t or t in ('-', 'N/A', '_'): return None neg = t.startswith('(') and t.endswith(')') # (1,234) = 음수 표기 대비 if neg: t = t[1:-1] try: v = float(t) return -v if neg else v except ValueError: return None def _txt(e) -> str: return (e.text or '').strip() if e is not None else '' def _field_key(field_name: str) -> str | None: for prefix, key in _FIELD_MAP.items(): if field_name.startswith(prefix): return key return None def _pct_growth(cur: float | None, prev: float | None) -> float | None: """전년대비 증가율(%). 직전이 0/음수면 의미 없어 None.""" if cur is None or prev is None or prev <= 0: return None return round((cur / prev - 1) * 100, 1) def _parse_annual(root) -> list[dict]: """financial_highlight_annual → 실적 확정 연도만 시계열(오래된→최신). (E) 추정 연도는 별도 highlight value가 비어있어 제외(컨센서스는 _parse_consensus). """ fha = root.find('.//financial_highlight_annual') if fha is None: return [] fields = [_txt(f) for f in fha.findall('field')] keys = [_field_key(f) for f in fields] rows: list[dict] = [] for rec in fha.findall('record'): period = _txt(rec.find('date')) if '(E)' in period: # 추정 연도 skip continue vals = [_txt(v) for v in rec.findall('value')] row: dict = {'period': period, 'fs_nm': _txt(rec.find('fs_nm'))} has_any = False for i, key in enumerate(keys): if key is None or i >= len(vals): continue n = _num(vals[i]) row[key] = n if n is not None: has_any = True if has_any: rows.append(row) return rows def _attach_growth(annual: list[dict]) -> None: """각 연도 행에 전년대비 증가율(%) 추가 (in-place).""" for i, row in enumerate(annual): prev = annual[i - 1] if i > 0 else None for base, out in (('sales', 'sales_growth'), ('oper_profit', 'oper_profit_growth'), ('eps', 'eps_growth'), ('net_profit', 'net_profit_growth')): row[out] = _pct_growth(row.get(base), prev.get(base) if prev else None) def _parse_consensus(root) -> dict | None: """ 블록 → 목표주가·투자의견·추정 EPS/PER·참여기관수.""" c = root.find('.//consensus') if c is None: return None out = { 'date': _txt(c.find('date')) or None, 'target_price': _num(_txt(c.find('target_price'))), 'opinion': _num(_txt(c.find('opinion'))), # 1(매도)~5(매수) 스케일 'eps': _num(_txt(c.find('eps'))), # 추정 EPS 'per': _num(_txt(c.find('per'))), # 추정 PER 'organ_count': _num(_txt(c.find('presume_organ_count'))), } # 전부 비면 의미 없음 if not any(v is not None for k, v in out.items() if k != 'date'): return None return out def _opinion_label(op: float | None) -> str | None: """투자의견 1~5 → 한글 라벨.""" if op is None: return None if op >= 4.5: return '적극매수' if op >= 3.5: return '매수' if op >= 2.5: return '중립' if op >= 1.5: return '매도' return '적극매도' def _fetch_xml(code6: str) -> str | None: url = SNAPSHOT_URL.format(code6=code6) req = urllib.request.Request(url, headers={ 'User-Agent': _UA, 'Referer': 'https://comp.fnguide.com/', }) try: with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: raw = resp.read() except Exception: return None if not raw: return None return raw.decode('euc-kr', 'replace') def _build(code6: str) -> dict | None: """XML fetch → 파싱 → 표준 dict. 실패/데이터 없으면 None.""" xml_text = _fetch_xml(code6) if not xml_text: return None try: root = ET.fromstring(xml_text) except ET.ParseError: return None annual = _parse_annual(root) _attach_growth(annual) consensus = _parse_consensus(root) if not annual and not consensus: # ETF·신규상장 등 펀더멘털 없음 return None latest = annual[-1] if annual else {} growth = { 'sales_yoy': latest.get('sales_growth'), 'oper_profit_yoy': latest.get('oper_profit_growth'), 'eps_yoy': latest.get('eps_growth'), 'net_profit_yoy': latest.get('net_profit_growth'), } if latest else {} if consensus and consensus.get('opinion') is not None: consensus['opinion_label'] = _opinion_label(consensus['opinion']) return { 'code': code6, 'fetched_at': time.strftime('%Y-%m-%dT%H:%M:%S+09:00', time.localtime()), 'annual': annual, # 오래된→최신, 확정 실적만 'latest_period': latest.get('period') if latest else None, 'growth': growth, # 최신 연도 YoY 요약 'consensus': consensus, # 목표주가·투자의견·추정치 (없으면 None) } def _cache_path(code6: str) -> Path: return CACHE_DIR / f'{code6}.json' def get_fundamentals(code: str, max_age_sec: int = CACHE_TTL_SEC, force: bool = False) -> dict | None: """종목 펀더멘털 dict 반환 (캐시 우선). 조회 불가 시 None — 절대 raise 안 함. 반환 스키마는 _build() 참조. ETF·없는 종목·네트워크 실패는 None. """ code6 = _norm_code(code) if not code6: return None cp = _cache_path(code6) if not force and cp.exists(): try: if time.time() - cp.stat().st_mtime < max_age_sec: return json.loads(cp.read_text()) except Exception: pass # 캐시 손상 → 재조회 data = _build(code6) if data is None: return None try: CACHE_DIR.mkdir(parents=True, exist_ok=True) tmp = cp.with_suffix('.json.tmp') tmp.write_text(json.dumps(data, ensure_ascii=False)) tmp.replace(cp) except Exception: pass # 캐시 저장 실패해도 데이터는 반환 return data def _cli() -> None: args = [a for a in sys.argv[1:] if not a.startswith('--')] force = '--fresh' in sys.argv if not args: print('usage: python3 fnguide_client.py [--fresh]') sys.exit(1) d = get_fundamentals(args[0], force=force) if d is None: print(f'{args[0]}: 펀더멘털 없음 (ETF/없는종목/조회실패)') sys.exit(0) print(json.dumps(d, ensure_ascii=False, indent=2)) if __name__ == '__main__': _cli()