4f63ec56dc
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
305 lines
12 KiB
Python
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())
|