Files
openclaw/agents/stock/workspace/scripts/ipo_alert.py
T
hyowons 4f63ec56dc auto: 일일 백업 2026-10-08 02:00
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-08 02:00:06 +09:00

305 lines
12 KiB
Python

#!/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())