Initial commit: OpenClaw 워크스페이스 버전관리 시작
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WISEreport(FnGuide 계열) 컨센서스 조회 (조회 전용, 캐시 우선).
|
||||
|
||||
FnGuide Snapshot(fnguide_client)이 못 주는 '시간축 컨센서스'를 보강한다:
|
||||
- 연도별 추정 재무 (IFRS연결, A 실적 + E 추정, 매출/영업이익/순이익/EPS/ROE + YoY)
|
||||
- 목표주가·추정치 리비전 추이 (최근 3개월 주간 — 상향/하향 모멘텀)
|
||||
- 어닝 서프라이즈 (시점별 컨센서스 vs 실적 괴리율)
|
||||
|
||||
데이터원: comp.wisereport.co.kr/company/ajax/{c1050001_data,cF5001}.aspx (JSON)
|
||||
|
||||
⚠️ 운영사가 FnGuide(WISEfn)로 fnguide_client와 동일 벤더. 같은 저작권·회색지대.
|
||||
→ 종목별 파일 캐시(기본 12h) + 보유·관심 종목 on-demand 조회로만 사용.
|
||||
|
||||
⚠️ 데이터 스케일 주의: 현재 피드에서 forward 추정 절대값(매출·EPS)이 과거 실적 대비
|
||||
크게 인플레이션돼 나오는 경우가 있다(현재가·목표가 동반). cutoff 가드는 두지 않고
|
||||
소스값 그대로 반환한다(메모리 규칙). 방향성 신호(리비전 %·서프라이즈 %)는 스케일 무관.
|
||||
|
||||
호출부는 예외에 의존하지 않는다 — 실패 시 None 반환.
|
||||
|
||||
CLI:
|
||||
python3 wisereport_client.py <code> [--fresh]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
|
||||
CACHE_DIR = WORKSPACE / 'state' / 'wisereport_cache'
|
||||
CACHE_TTL_SEC = 12 * 3600
|
||||
|
||||
_BASE = 'https://comp.wisereport.co.kr/company/ajax'
|
||||
_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
|
||||
|
||||
# 서프라이즈/리비전 대상 지표 (acc_cd → 라벨)
|
||||
_SUP_ITEMS = {'121000': '매출액', '121500': '영업이익'}
|
||||
|
||||
|
||||
def _norm_code(code: str) -> str:
|
||||
s = ''.join(ch for ch in str(code) if ch.isdigit())
|
||||
return s.zfill(6)[-6:] if s else ''
|
||||
|
||||
|
||||
def _num(s) -> float | None:
|
||||
if s is None:
|
||||
return None
|
||||
if isinstance(s, (int, float)):
|
||||
return float(s)
|
||||
t = str(s).strip().replace(',', '')
|
||||
if not t or t in ('-', 'N/A'):
|
||||
return None
|
||||
try:
|
||||
return float(t)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _today_kst() -> str:
|
||||
return time.strftime('%Y%m%d', time.localtime())
|
||||
|
||||
|
||||
def _get_json(url: str, referer: str) -> dict | list | None:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': _UA, 'Referer': referer})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
|
||||
raw = resp.read()
|
||||
except Exception:
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw.decode('utf-8', 'replace'))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_annual(code6: str, sdt: str, ref: str) -> list[dict]:
|
||||
"""flag=2 → 연도별 추정 재무 (A 실적 + E 추정, IFRS연결)."""
|
||||
url = (f'{_BASE}/c1050001_data.aspx?cmp_cd={code6}&finGubun=MAIN&frq=0'
|
||||
f'&sDT={sdt}&chartType=svg&flag=2')
|
||||
d = _get_json(url, ref)
|
||||
rows = (d or {}).get('JsonData') if isinstance(d, dict) else None
|
||||
if not rows:
|
||||
return []
|
||||
out = []
|
||||
for r in rows:
|
||||
yymm = (r.get('YYMM') or '').strip() # "2025.12(A)" / "2026.12(E)"
|
||||
is_est = '(E)' in yymm
|
||||
period = yymm.replace('(A)', '').replace('(E)', '').replace('.', '/')
|
||||
out.append({
|
||||
'period': period,
|
||||
'is_estimate': is_est,
|
||||
'sales': _num(r.get('SALES')),
|
||||
'sales_yoy': _num(r.get('YOY')),
|
||||
'op': _num(r.get('OP')),
|
||||
'np': _num(r.get('NP')),
|
||||
'eps': _num(r.get('EPS')),
|
||||
'bps': _num(r.get('BPS')),
|
||||
'per': _num(r.get('PER')),
|
||||
'pbr': _num(r.get('PBR')),
|
||||
'roe': _num(r.get('ROE')),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _parse_revision(code6: str, sdt: str, yymm: str, ref: str) -> dict | None:
|
||||
"""cF5001 → 목표주가·추정치(EPS) 주간 추이 + 변화율 요약."""
|
||||
url = (f'{_BASE}/cF5001.aspx?cmp_cd={code6}&dt={sdt}&yymm={yymm}&frq=0'
|
||||
f'&acc_cd=121000&fingubun=MAIN&chartType=svg')
|
||||
d = _get_json(url, ref)
|
||||
if not isinstance(d, dict) or 'chart1' not in d:
|
||||
return None
|
||||
try:
|
||||
c = json.loads(d['chart1'])
|
||||
except Exception:
|
||||
return None
|
||||
dates = c.get('categories') or []
|
||||
tp = [x for x in (c.get('target_price') or []) if isinstance(x, (int, float))]
|
||||
est = [x for x in (c.get('select_item') or []) if isinstance(x, (int, float))]
|
||||
|
||||
def _chg(seq):
|
||||
if len(seq) >= 2 and seq[0]:
|
||||
return round((seq[-1] / seq[0] - 1) * 100, 1)
|
||||
return None
|
||||
|
||||
if not tp and not est:
|
||||
return None
|
||||
return {
|
||||
'item_name': c.get('select_item_name'),
|
||||
'unit': c.get('select_item_unit'),
|
||||
'dates': dates,
|
||||
'target_price': c.get('target_price') or [],
|
||||
'est_item': c.get('select_item') or [],
|
||||
'target_first': tp[0] if tp else None,
|
||||
'target_last': tp[-1] if tp else None,
|
||||
'target_change_pct': _chg(tp),
|
||||
'est_first': est[0] if est else None,
|
||||
'est_last': est[-1] if est else None,
|
||||
'est_change_pct': _chg(est),
|
||||
'n_points': len(dates),
|
||||
}
|
||||
|
||||
|
||||
def _parse_surprise(code6: str, sdt: str, ref: str) -> dict | None:
|
||||
"""flag=5 → 매출·영업이익 시점별 컨센서스 vs 실적 + 최신연도 서프라이즈%."""
|
||||
items: dict[str, dict] = {}
|
||||
years = None
|
||||
for acc_cd, label in _SUP_ITEMS.items():
|
||||
url = (f'{_BASE}/c1050001_data.aspx?cmp_cd={code6}&finGubun=MAIN&frq=0'
|
||||
f'&sDT={sdt}&chartType=svg&flag=5&acc_cd={acc_cd}')
|
||||
d = _get_json(url, ref)
|
||||
td = (d or {}).get('tableData') if isinstance(d, dict) else None
|
||||
rows = (td or {}).get('tableData') if isinstance(td, dict) else None
|
||||
if not rows:
|
||||
continue
|
||||
if years is None:
|
||||
hdr = (td.get('tableHeaderData') or [{}])[0]
|
||||
years = {k: hdr.get(k) for k in ('CNS_FY_2', 'CNS_FY_1', 'CNS_FY0', 'CNS_FY1')}
|
||||
# 최신 완료연도(FY0) 실적 + 발표직전(E) 서프라이즈%
|
||||
actual_fy0 = pre_surprise = pre_est = None
|
||||
for row in rows:
|
||||
qtr = (row.get('QTR') or '').strip()
|
||||
if qtr == '연간실적(A)':
|
||||
actual_fy0 = _num(row.get('FY0'))
|
||||
elif qtr == '발표직전(E)':
|
||||
pre_est = _num(row.get('FY0'))
|
||||
pre_surprise = _num(row.get('FY0_S')) # 괴리율 %
|
||||
items[label] = {
|
||||
'fy0_year': (years or {}).get('CNS_FY0'),
|
||||
'fy0_actual': actual_fy0,
|
||||
'fy0_consensus': pre_est,
|
||||
'fy0_surprise_pct': pre_surprise,
|
||||
}
|
||||
# 실데이터(연도 라벨 + 실적/서프라이즈) 하나도 없으면 None (ETF·없는종목).
|
||||
has_real = bool(years and any((years or {}).values())) and any(
|
||||
v.get('fy0_actual') is not None or v.get('fy0_surprise_pct') is not None
|
||||
for v in items.values()
|
||||
)
|
||||
if not has_real:
|
||||
return None
|
||||
return {'years': years, 'items': items}
|
||||
|
||||
|
||||
def _build(code6: str) -> dict | None:
|
||||
sdt = _today_kst()
|
||||
ref = f'https://comp.wisereport.co.kr/company/c1050001.aspx?cmp_cd={code6}'
|
||||
annual = _parse_annual(code6, sdt, ref)
|
||||
# cF5001 yymm: 첫 추정연도(E) 기준, 없으면 최신연도
|
||||
yymm = '000000'
|
||||
est_years = [a['period'] for a in annual if a.get('is_estimate')]
|
||||
if est_years:
|
||||
yymm = est_years[0].replace('/', '') # "2026/12" → "202612"
|
||||
elif annual:
|
||||
yymm = annual[-1]['period'].replace('/', '')
|
||||
revision = _parse_revision(code6, sdt, yymm, ref)
|
||||
surprise = _parse_surprise(code6, sdt, ref)
|
||||
|
||||
if not annual and not revision and not surprise:
|
||||
return None
|
||||
return {
|
||||
'code': code6,
|
||||
'fetched_at': time.strftime('%Y-%m-%dT%H:%M:%S+09:00', time.localtime()),
|
||||
'annual': annual, # IFRS연결, A+E
|
||||
'revision': revision, # 목표주가·추정EPS 추이
|
||||
'surprise': surprise, # 매출·영업이익 서프라이즈
|
||||
}
|
||||
|
||||
|
||||
def _cache_path(code6: str) -> Path:
|
||||
return CACHE_DIR / f'{code6}.json'
|
||||
|
||||
|
||||
def get_consensus(code: str, max_age_sec: int = CACHE_TTL_SEC,
|
||||
force: bool = False) -> dict | None:
|
||||
"""종목 컨센서스 dict 반환 (캐시 우선). 조회 불가 시 None — raise 안 함."""
|
||||
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
|
||||
|
||||
|
||||
import re as _re
|
||||
|
||||
|
||||
def _clean_summary(html: str | None, max_bullets: int = 3) -> list[str]:
|
||||
"""COMMENT2(HTML) → bullet 텍스트 리스트. ▶/<br> 기준 분리, 태그 제거."""
|
||||
if not html:
|
||||
return []
|
||||
t = html.replace('\r\n', '\n')
|
||||
t = _re.sub(r'<br\s*/?>', '\n', t, flags=_re.I)
|
||||
t = _re.sub(r'<[^>]+>', '', t) # 태그 제거
|
||||
t = t.replace('▶', '\n')
|
||||
bullets = [b.strip(' \t·-') for b in _re.split(r'\n+', t)]
|
||||
bullets = [b for b in bullets if b]
|
||||
return bullets[:max_bullets]
|
||||
|
||||
|
||||
def _reports_cache_path(code6: str) -> Path:
|
||||
return CACHE_DIR / f'{code6}_reports.json'
|
||||
|
||||
|
||||
def get_reports(code: str, limit: int = 8, max_age_sec: int = 6 * 3600,
|
||||
force: bool = False) -> list[dict] | None:
|
||||
"""최근 증권사 분석리포트 목록. 조회 불가·없음 시 None — raise 안 함.
|
||||
|
||||
각 항목: {date, broker, broker_full, title, target, recomm,
|
||||
target_action, recomm_action, analyst, summary[]}
|
||||
리포트는 수시 갱신 → 캐시 6h. PDF 원문은 게이팅 가능성으로 제외(메타+요약만).
|
||||
"""
|
||||
code6 = _norm_code(code)
|
||||
if not code6:
|
||||
return None
|
||||
cp = _reports_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
|
||||
|
||||
ref = f'https://comp.wisereport.co.kr/company/c1080001.aspx?cmp_cd={code6}'
|
||||
d = _get_json(f'{_BASE}/c1080001_data.aspx?cmp_cd={code6}', ref)
|
||||
rows = (d or {}).get('lists') if isinstance(d, dict) else None
|
||||
if not rows:
|
||||
return None
|
||||
out = []
|
||||
for r in rows[:limit]:
|
||||
title = (r.get('PRE_TITLE') or '') + (r.get('RPT_TITLE') or '')
|
||||
out.append({
|
||||
'date': (r.get('ANL_DT') or '').strip(),
|
||||
'broker': (r.get('BRK_NM_SHORT_KOR') or r.get('BRK_NM_KOR') or '').strip(),
|
||||
'broker_full': (r.get('BRK_NM_KOR') or '').strip(),
|
||||
'title': title.strip(),
|
||||
'target': (r.get('TARGET_PRC') or '').strip() or None,
|
||||
'recomm': (r.get('RECOMM') or '').strip() or None,
|
||||
'target_action': (r.get('PRC_ACTION_TYP_NM') or '').strip() or None,
|
||||
'recomm_action': (r.get('RECOMM_ACTION_TYP_NM') or '').strip() or None,
|
||||
'analyst': (r.get('ANL_NM_KOR') or '').strip() or None,
|
||||
'summary': _clean_summary(r.get('COMMENT2')),
|
||||
})
|
||||
if not out:
|
||||
return None
|
||||
try:
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = cp.with_suffix('.json.tmp')
|
||||
tmp.write_text(json.dumps(out, ensure_ascii=False))
|
||||
tmp.replace(cp)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _cli() -> None:
|
||||
args = [a for a in sys.argv[1:] if not a.startswith('--')]
|
||||
if not args:
|
||||
print('usage: python3 wisereport_client.py <code> [--fresh] [--reports]')
|
||||
sys.exit(1)
|
||||
if '--reports' in sys.argv:
|
||||
rs = get_reports(args[0], force=('--fresh' in sys.argv))
|
||||
print(json.dumps(rs, ensure_ascii=False, indent=2) if rs else f'{args[0]}: 리포트 없음')
|
||||
sys.exit(0)
|
||||
d = get_consensus(args[0], force=('--fresh' in sys.argv))
|
||||
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()
|
||||
Reference in New Issue
Block a user