#!/usr/bin/env python3 from __future__ import annotations import argparse import json import subprocess import sys from datetime import date, datetime from decimal import Decimal, ROUND_HALF_UP from pathlib import Path from ipo_calendar_sync import ( KST, NAVER_IPO_API_URL, _naver_api_rows, fetch, send_telegram, ) STATE_FILE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace/state/ipo_alerts.json') # 온라인 일반 고객 기준. 고객등급·배정 여부에 따라 면제될 수 있다. # 확인이 끝난 증권사만 숫자를 두고, 미확인 증권사는 알림에서 추정하지 않는다. BROKER_ONLINE_FEES = { '대신증권': 2_000, '하나증권': 2_000, '미래에셋증권': 2_000, '삼성증권': 2_000, 'KB증권': 1_500, '유진투자증권': 2_000, '한국투자증권': 2_000, 'IBK투자증권': 0, } def load_state() -> dict: try: return json.loads(STATE_FILE.read_text()) except (FileNotFoundError, json.JSONDecodeError): return {'sent': {}} def save_state(state: dict) -> None: STATE_FILE.parent.mkdir(parents=True, exist_ok=True) STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2)) def _int(value) -> int | None: try: return int(str(value).replace(',', '').strip()) except (TypeError, ValueError): return None def _money(value: int | None) -> str: return '미확인' if value is None else f'{value:,}원' def _detail(ipo_code: str) -> dict: url = f'https://stock.naver.com/api/domestic/ipo/{ipo_code}/detail/info' return json.loads(fetch(url, encoding='utf-8')) def _date_range(start: str, end: str) -> str: return start if not end or start == end else f'{start} ~ {end}' def _required_deposit(total: int, ratio) -> int | None: try: pct = Decimal(str(ratio)) except Exception: return None return int((Decimal(total) * pct / Decimal(100)).quantize(Decimal('1'), rounding=ROUND_HALF_UP)) def _manager_blocks(row: dict, detail: dict) -> list[dict]: info = detail.get('registrationStatement') or {} ratio = info.get('sbscMrgnRatio') or '50' price = _int((detail.get('demandForecastingResult') or {}).get('fixPubPrice')) if price is None: price = _int(row.get('fixPubPrice')) managers = detail.get('joinManagers') or [] if not managers: names = [x.strip() for x in (row.get('orgNm') or '').split(',') if x.strip()] managers = [{'orgNm': name, 'sbscMinNum': None} for name in names] if not managers: managers = [{'orgNm': '미확인', 'sbscMinNum': None}] blocks = [] for manager in managers: broker = (manager.get('orgNm') or '미확인').strip() min_qty = _int(manager.get('sbscMinNum')) total = price * min_qty if price is not None and min_qty is not None else None deposit = _required_deposit(total, ratio) if total is not None else None fee = BROKER_ONLINE_FEES.get(broker) blocks.append({ 'broker': broker, 'min_qty': min_qty, 'price': price, 'total': total, 'ratio': ratio, 'deposit': deposit, 'fee': fee, }) return blocks def format_alert(kind: str, row: dict, detail: dict) -> str: name = (row.get('compName') or (detail.get('ipoInfo') or {}).get('compName') or '미확인').strip() info = detail.get('ipoInfo') or {} if kind == 'subscription': heading = '[공모주 청약]' date_label = '청약일' date_value = _date_range(info.get('poStartDate') or row.get('poStartDate') or '', info.get('poEndDate') or row.get('poEndDate') or '') else: heading = '[신규 상장]' date_label = '상장일' date_value = info.get('lcalDate') or row.get('lcalDate') or '' sections = [] for block in _manager_blocks(row, detail): price = block['price'] qty = block['min_qty'] if block['deposit'] is not None and qty is not None: cost = f"{block['deposit']:,}원(최소 {qty:,}주)" elif price is not None: cost = '최소청약수량 미확인' else: cost = '미확정' fee = block['fee'] cost += f" / 수수료 {_money(fee)}(온라인 일반 기준)" sections.append('\n'.join([ heading, f'종목명 : {name}', f"증권사 : {block['broker']}", f'{date_label} : {date_value}', f'증거금 : {cost}', ])) return '\n\n'.join(sections) GAHEE_CRED = Path('/Users/snowoyh/.openclaw/credentials/gahee_imessage.json') IPO_SUBSCRIBED = Path('/Users/snowoyh/.openclaw/agents/stock/workspace/state/behive_ipo_subscribed.json') def gahee_listing_message(target: date, rows: list[tuple[dict, dict]] | None) -> tuple[list[str], str]: """가희가 청약한 종목 중 target 상장분 → (ipoCode 목록, 문자 본문). 없으면 ([], ''). 상장일은 API 상장 목록 ∪ 청약기록 lcal_date(네이버 조회 실패해도 기록으로 보낸다).""" try: subscribed = json.loads(IPO_SUBSCRIBED.read_text()) except Exception: return [], '' by_code = {(r.get('ipoCode') or r.get('itemCode') or '').strip(): (r, d) for r, d in (rows or [])} codes, blocks = [], [] for code, rec in subscribed.items(): broker = ((rec.get('by') or {}).get('gahee') or {}).get('broker') if not broker or (code not in by_code and rec.get('lcal_date') != target.isoformat()): continue row, detail = by_code.get(code, ({}, None)) if detail is None: try: detail = _detail(code) except Exception: detail = {} price = next((b['price'] for b in _manager_blocks(row, detail) if b.get('price') is not None), None) lines = [f"[공모주 상장] 오늘 {rec.get('name') or row.get('compName') or code} 상장일이에요", f'· 청약 증권사: {broker}'] if price is not None: lines.append(f'· 확정 공모가: {price:,}원') lines.append(f'· 09:00 거래 시작 — 배정 주식은 {broker} 앱에서 확인하세요') codes.append(code) blocks.append('\n'.join(lines)) return codes, '\n\n'.join(blocks) def send_gahee_imessage(text: str) -> bool: """가희 리마인더(gahee_reminder._send_imessage)와 같은 방식 — imessage 강제(sms 는 이 수신자에게 3전 3패). ⚠️ rc=0 은 Messages 에 넘겼다는 뜻일 뿐 전달 확인이 아니다. 코디 셸에선 AppleEvents 거부로 안 되고 launchd 경유로만.""" handle = json.loads(GAHEE_CRED.read_text()).get('handle') if not handle: return False p = subprocess.run(['imsg', 'send', '--to', handle, '--text', text, '--service', 'imessage'], capture_output=True, text=True, timeout=30) if p.returncode != 0: print(f'gahee imsg send rc={p.returncode}: {p.stderr[:200]}', file=sys.stderr) return p.returncode == 0 def collect_for_date(kind: str, target: date) -> list[tuple[dict, dict]]: payload = json.loads(fetch(NAVER_IPO_API_URL, encoding='utf-8')) matches = [] field = 'poStartDate' if kind == 'subscription' else 'lcalDate' for row in _naver_api_rows(payload): if row.get(field) != target.isoformat(): continue code = (row.get('ipoCode') or row.get('itemCode') or '').strip() if not code: continue matches.append((row, _detail(code))) return matches def main() -> int: parser = argparse.ArgumentParser() parser.add_argument('kind', choices=('subscription', 'listing')) parser.add_argument('--date', help='YYYY-MM-DD, 기본값은 오늘(KST)') parser.add_argument('--dry-run', action='store_true') args = parser.parse_args() target = date.fromisoformat(args.date) if args.date else datetime.now(KST).date() try: rows = collect_for_date(args.kind, target) except Exception as exc: print(f'IPO alert source failed: {type(exc).__name__}: {exc}', file=sys.stderr) rows = None if args.kind == 'listing' and not args.dry_run: # 자산웹에서 청약완료한 종목을 상장일에 관심종목으로 등록. 알림 소스가 죽어도 # 청약완료 기록의 상장일로 시도한다(그래서 위에서 바로 return 하지 않는다). listed = {(r.get('ipoCode') or r.get('itemCode') or '').strip() for r, _ in (rows or [])} try: from behive_web import register_listed_ipo_interests added = register_listed_ipo_interests(target.isoformat(), listed) if added: print(f'IPO interests added: {", ".join(added)}') except Exception as exc: print(f'IPO interests add failed: {type(exc).__name__}: {exc}', file=sys.stderr) if not args.dry_run: # 비하이브 '공모청약' 영상 요약 → 자산웹 공모주 탭. 새 영상 없으면 채널 페이지 1콜로 끝난다. try: from ipo_youtube_digest import run as run_ipo_youtube print(f'IPO youtube digest: {json.dumps(run_ipo_youtube(), ensure_ascii=False)}') except Exception as exc: print(f'IPO youtube digest failed: {type(exc).__name__}: {exc}', file=sys.stderr) if args.kind == 'listing': # 가희가 청약한 종목의 상장일 아침 iMessage(2026-10-07 관리자님 요청). 같은 날 여러 종목이면 한 통. try: codes, text = gahee_listing_message(target, rows) state = load_state() key = f'gahee_listing|{target.isoformat()}|{",".join(sorted(codes))}' if not codes: pass elif args.dry_run: print(f'[dry-run] 가희 상장 문자:\n{text}\n') elif key not in state.get('sent', {}): if send_gahee_imessage(text): state.setdefault('sent', {})[key] = datetime.now(KST).isoformat() save_state(state) print(f'gahee listing imessage sent: {",".join(codes)}') except Exception as exc: print(f'gahee listing imessage failed: {type(exc).__name__}: {exc}', file=sys.stderr) if rows is None: return 1 state = load_state() sent = state.setdefault('sent', {}) delivered = 0 skipped = 0 for row, detail in rows: code = (row.get('ipoCode') or row.get('itemCode') or '').strip() key = f'{args.kind}|{target.isoformat()}|{code}' if key in sent and not args.dry_run: skipped += 1 continue message = format_alert(args.kind, row, detail) if args.dry_run: print(message) print() continue if not send_telegram(message): print(f'IPO alert send failed: {key}', file=sys.stderr) return 1 sent[key] = datetime.now(KST).isoformat() delivered += 1 if not args.dry_run: # 상태 파일이 무한히 커지지 않도록 최근 120일만 보존한다. cutoff = date.fromordinal(target.toordinal() - 120).isoformat() state['sent'] = { key: value for key, value in sent.items() if len(key.split('|')) < 2 or key.split('|')[1] >= cutoff } state['last_run_at'] = datetime.now(KST).isoformat() state['last_kind'] = args.kind state['last_target_date'] = target.isoformat() state['last_found'] = len(rows) state['last_delivered'] = delivered state['last_skipped'] = skipped save_state(state) print(json.dumps({ 'kind': args.kind, 'target_date': target.isoformat(), 'found': len(rows), 'delivered': delivered, 'skipped': skipped, 'dry_run': args.dry_run, }, ensure_ascii=False)) return 0 if __name__ == '__main__': raise SystemExit(main())