Files
openclaw/agents/stock/workspace/scripts/fnguide_client.py
T
hyowons fed3526b20 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:39:41 +09:00

277 lines
9.1 KiB
Python

#!/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 <code> # 펀더멘털 출력
python3 fnguide_client.py <code> --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:
"""<consensus> 블록 → 목표주가·투자의견·추정 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 <code> [--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()