#!/usr/bin/env python3 from __future__ import annotations import argparse import json 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) 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 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())