자산웹 공모주 탭 추가 + 상장일 관심종목 자동등록
- 자산정보 > 공모주 sub-tab: 오늘청약(증권사별 아이템, 기본 접힘) / 청약완료(상장일까지) - 관리자(청약)·가희(청약) 버튼 — 사람별 종목당 1건 기록 (state/behive_ipo_subscribed.json) - 타이틀 [청약] 배지: 미완료 청약 또는 청약 종목 상장일 - ipo_alert listing(08:30)에서 청약완료 종목을 상장일에 관심종목 등록 - 데이터는 네이버 IPO API, 증거금·수수료는 ipo_alert._manager_blocks 재사용(키움 콜 0) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,7 @@ CHART_HLINES = WORKSPACE / 'state' / 'behive_chart_hlines.json'
|
||||
INTEREST_GROUPS = WORKSPACE / 'state' / 'behive_interest_groups.json'
|
||||
HOLDEVAL_SAVED = WORKSPACE / 'state' / 'behive_holdeval_saved.json'
|
||||
SURGE_TOGGLES = WORKSPACE / 'state' / 'behive_surge_toggles.json'
|
||||
IPO_SUBSCRIBED = WORKSPACE / 'state' / 'behive_ipo_subscribed.json'
|
||||
ALERTS_STATE = WORKSPACE / 'state' / 'watchlist_alerts.json'
|
||||
HOLIDAYS_FILE = WORKSPACE / 'state' / 'market_holidays.json'
|
||||
SNAPSHOT_FILE = WORKSPACE / 'state' / 'portfolio_daily_snapshot.json'
|
||||
@@ -1831,7 +1832,12 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
|
||||
# NXT 미거래 종목은 AL == KRX 라 결과가 같다. AL 이 불완전할 때만 KRX 로 폴백.
|
||||
qk_full = qk_valid and qk.get('open') and qk.get('high') and qk.get('low')
|
||||
qa_full = qa_valid and qa.get('open') and qa.get('high') and qa.get('low')
|
||||
primary = qa if qa_full else (qk if qk_full else None)
|
||||
# ⚠️ 정규장(09:00~15:30)엔 KRX 우선 — 공식 시세가 KRX 라 통합(프리마켓 시가 포함)을 보이면
|
||||
# 시가가 시장 표기와 어긋난다(2026-10-02 관리자님 지적, 삼성전자 KRX 273,500 vs 통합 275,500).
|
||||
if _phase == 'regular':
|
||||
primary = qk if qk_full else (qa if qa_full else None)
|
||||
else:
|
||||
primary = qa if qa_full else (qk if qk_full else None)
|
||||
if primary:
|
||||
r['ohlc'] = {'o': primary['open'], 'h': primary['high'], 'l': primary['low'], 'c': primary.get('price', 0) or r.get('price', 0)}
|
||||
|
||||
@@ -2241,6 +2247,119 @@ def _set_stock_note(code: str | None, name: str | None, text: str) -> None:
|
||||
tmp.replace(STOCK_NOTES)
|
||||
|
||||
|
||||
# ---- 공모주 (자산정보 > 공모주 sub-tab) ----
|
||||
# 데이터원은 ipo_alert.py(텔레그램 청약 알림)와 같은 네이버 IPO API. 증거금·수수료 계산도
|
||||
# ipo_alert._manager_blocks 를 그대로 써서 텔레그램과 화면 숫자가 어긋나지 않게 한다. 키움 콜 0.
|
||||
IPO_TTL = 1800.0 # 일정은 하루 몇 번만 바뀐다
|
||||
_ipo_cache: dict = {'rows': None, 'expires_at': 0.0, 'detail': {}}
|
||||
_ipo_lock = threading.Lock()
|
||||
|
||||
|
||||
def _fetch_ipo_rows() -> list[dict] | None:
|
||||
"""네이버 IPO 진행 목록(종목 단위 병합). 30분 캐시, 만료돼도 조회 실패면 옛 값 유지. 첫 조회 실패 시 None."""
|
||||
now = time.time()
|
||||
with _ipo_lock:
|
||||
cached = _ipo_cache['rows']
|
||||
if cached is not None and _ipo_cache['expires_at'] > now:
|
||||
return cached
|
||||
try:
|
||||
from ipo_calendar_sync import NAVER_IPO_API_URL, _naver_api_rows, fetch as _ipo_fetch
|
||||
rows = _naver_api_rows(json.loads(_ipo_fetch(NAVER_IPO_API_URL, encoding='utf-8')))
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'ipo rows fetch failed: {type(e).__name__}: {e}\n')
|
||||
return cached
|
||||
with _ipo_lock:
|
||||
_ipo_cache['rows'] = rows
|
||||
_ipo_cache['expires_at'] = now + IPO_TTL
|
||||
return rows
|
||||
|
||||
|
||||
def _ipo_detail_cached(code: str) -> dict | None:
|
||||
"""종목 상세(주관사별 최소청약수량·확정공모가). 종목별 30분 캐시. 실패 시 옛 값 또는 None."""
|
||||
now = time.time()
|
||||
with _ipo_lock:
|
||||
hit = _ipo_cache['detail'].get(code)
|
||||
if hit and hit[1] > now:
|
||||
return hit[0]
|
||||
try:
|
||||
from ipo_alert import _detail
|
||||
d = _detail(code)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'ipo detail fetch failed {code}: {type(e).__name__}: {e}\n')
|
||||
return hit[0] if hit else None
|
||||
with _ipo_lock:
|
||||
_ipo_cache['detail'][code] = (d, now + IPO_TTL)
|
||||
return d
|
||||
|
||||
|
||||
def _ipo_subscribed_lock():
|
||||
"""IPO_SUBSCRIBED 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
|
||||
import fcntl as _fcntl
|
||||
from contextlib import contextmanager as _cm
|
||||
|
||||
@_cm
|
||||
def _ctx():
|
||||
lock_path = IPO_SUBSCRIBED.with_suffix(IPO_SUBSCRIBED.suffix + '.lock')
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
f = open(lock_path, 'a')
|
||||
try:
|
||||
_fcntl.flock(f.fileno(), _fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
_fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
|
||||
finally:
|
||||
f.close()
|
||||
return _ctx()
|
||||
|
||||
|
||||
IPO_WHO = {'self': '관리자', 'gahee': '가희'} # 청약완료 버튼 주체(관리자님 지정 — 사람별 1건씩)
|
||||
|
||||
|
||||
def _load_ipo_subscribed() -> dict:
|
||||
"""{ipoCode: {name, po_start, po_end, lcal_date, by: {self|gahee: {broker, at}}, interest_added?}}.
|
||||
중복청약 금지는 1인 기준이라 사람별로 종목당 1건. 옛 형식(종목 단위 broker/at)은 관리자 기록으로 읽는다."""
|
||||
try:
|
||||
d = json.loads(IPO_SUBSCRIBED.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
if not isinstance(d, dict):
|
||||
return {}
|
||||
for rec in d.values():
|
||||
if 'by' not in rec:
|
||||
rec['by'] = {'self': {'broker': rec.pop('broker', ''), 'at': rec.pop('at', '')}} if rec.get('broker') else {}
|
||||
return d
|
||||
|
||||
|
||||
def _set_ipo_subscribed(code: str, broker: str, who: str) -> dict:
|
||||
"""청약완료 기록. 그 사람이 이미 기록한 종목이면 기존 기록을 그대로 둔다(중복청약 금지 — 1인 1건)."""
|
||||
code = (code or '').strip()
|
||||
broker = (broker or '').strip()
|
||||
if not code or not broker:
|
||||
raise ValueError('종목코드·증권사가 필요합니다')
|
||||
if who not in IPO_WHO:
|
||||
raise ValueError('청약자가 올바르지 않습니다')
|
||||
row = next((r for r in (_fetch_ipo_rows() or [])
|
||||
if (r.get('ipoCode') or r.get('itemCode') or '').strip() == code), None)
|
||||
if row is None:
|
||||
raise ValueError('공모주 목록에서 종목을 찾지 못했습니다')
|
||||
with _ipo_subscribed_lock():
|
||||
d = _load_ipo_subscribed()
|
||||
rec = d.setdefault(code, {'by': {}})
|
||||
rec.update({
|
||||
'name': (row.get('compName') or '').strip(),
|
||||
'po_start': row.get('poStartDate') or '',
|
||||
'po_end': row.get('poEndDate') or '',
|
||||
'lcal_date': row.get('lcalDate') or rec.get('lcal_date') or '',
|
||||
})
|
||||
if who not in rec['by']:
|
||||
rec['by'][who] = {'broker': broker, 'at': datetime.now(KST).isoformat(timespec='seconds')}
|
||||
tmp = IPO_SUBSCRIBED.with_suffix('.json.tmp')
|
||||
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
|
||||
tmp.replace(IPO_SUBSCRIBED)
|
||||
return rec['by'][who]
|
||||
|
||||
|
||||
def _holdeval_lock():
|
||||
"""HOLDEVAL_SAVED 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
|
||||
import fcntl as _fcntl
|
||||
@@ -3886,13 +4005,20 @@ def _render_ohlc_mini_dual(krx: dict | None, al: dict | None, active_market: str
|
||||
extra = '' # 현재가 셀에만 WS 라이브 갱신 마커(data-rt-ohlc-cur) + 전일종가(data-prev) 부착.
|
||||
if key == 'price':
|
||||
# 현재가는 AL(통합) 가격을 양 컬럼 공통으로 표시(동일가). 등락률만 각 시장 전일종가 대비로 분리.
|
||||
price = al_price or d.get('price', 0)
|
||||
base = (d.get('prev_close', 0) or 0) if al_price else 0
|
||||
if al_price and base:
|
||||
pct = (price - base) / base * 100
|
||||
# ⚠️ 정규장(active_market='krx')의 KRX 열은 KRX 자체 현재가 — 실시간 틱은 통합(_AL)뿐이라
|
||||
# 라이브 마커(data-rt-ohlc-cur)도 달지 않는다(달면 통합 틱이 KRX 값을 덮어쓴다).
|
||||
if d is krx and active_market == 'krx':
|
||||
price = d.get('price', 0)
|
||||
base = d.get('prev_close', 0) or 0
|
||||
pct = ((price - base) / base * 100) if base else (d.get('change_pct', 0.0) or 0.0)
|
||||
else:
|
||||
pct = d.get('change_pct', 0.0) or 0.0
|
||||
extra = f' data-rt-ohlc-cur data-prev="{int(base) if base else 0}"'
|
||||
price = al_price or d.get('price', 0)
|
||||
base = (d.get('prev_close', 0) or 0) if al_price else 0
|
||||
if al_price and base:
|
||||
pct = (price - base) / base * 100
|
||||
else:
|
||||
pct = d.get('change_pct', 0.0) or 0.0
|
||||
extra = f' data-rt-ohlc-cur data-prev="{int(base) if base else 0}"'
|
||||
elif key == 'close_today':
|
||||
# 당일종가 — 그 시장이 마감된 뒤에만 그 시장의 종가(=ka10095 price)를 표시. 장중엔 미확정(·).
|
||||
if not closed:
|
||||
@@ -4500,7 +4626,8 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
|
||||
# 활성 열 판정 — OHLC 미니테이블 강조용. 두 열은 'KRX 단독' vs '하루 전체 통합'이라
|
||||
# 장중이면 항상 통합 열이 최신(현재가와 같은 값)이고, KRX 열은 15:30 이후 마감값으로 고정된다.
|
||||
_phase = _market_phase_state()['phase']
|
||||
mark = 'AL' if _phase in _PHASE_TRADING else 'NONE'
|
||||
# 정규장은 KRX 열을 강조 — 공식 시세가 KRX 다(2026-10-02 관리자님 지적).
|
||||
mark = 'KRX' if _phase == 'regular' else ('AL' if _phase in _PHASE_TRADING else 'NONE')
|
||||
# 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음. (미리보기 행과 동일 규칙)
|
||||
# 정규장: 전부 / NXT 시간대: nxt_enable 만 / 애프터마켓: ETF/ETN 빼고 전부 / 장외·휴장·주말: 없음
|
||||
_tradable = _phase_tradable(_phase, r.get('code') or '')
|
||||
@@ -5861,6 +5988,8 @@ header.top {
|
||||
padding: 10px 16px; padding-top: max(10px, env(safe-area-inset-top));
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 10px;
|
||||
}
|
||||
header.top h1 .ipo-flag { margin-left: 2px; }
|
||||
header.top h1 .ipo-flag-b { margin-left: 4px; display: inline-block; font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 999px; vertical-align: 2px; background: rgba(255,77,94,0.15); color: #ff8a96; border: 1px solid rgba(255,77,94,0.32); }
|
||||
header.top .titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
header.top h1 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; color: #f0f0f0; animation: refresh-flash 0.8s ease-out; }
|
||||
header.top .meta { font-size: 11px; color: #8b8f9a; font-variant-numeric: tabular-nums; animation: refresh-flash-meta 0.8s ease-out; }
|
||||
@@ -7076,6 +7205,36 @@ table.adr-info-table td.adr-breakdown { font-size: 11px; color: #8b8f9a; white-s
|
||||
font-size: 9px; font-weight: 600; letter-spacing: 0.04em;
|
||||
color: #fbbf24;
|
||||
}
|
||||
/* 공모주 sub-tab — 오늘청약(종목 × 증권사 아이템) / 청약완료. 확인 팝업은 .note-confirm 재사용. */
|
||||
.ipo-card { margin: 10px 14px; }
|
||||
.ipo-sec-title { font-size: 13px; font-weight: 600; color: #e6e8ec; margin: 14px 0 8px; }
|
||||
.ipo-sec-title:first-child { margin-top: 4px; }
|
||||
.ipo-sub { font-size: 11px; font-weight: 400; color: #8b8f9a; }
|
||||
.ipo-empty { font-size: 12px; color: #8b8f9a; padding: 12px; background: #141720; border: 1px solid #1f2330; border-radius: 10px; }
|
||||
.ipo-stock { background: #141720; border: 1px solid #1f2330; border-radius: 10px; padding: 10px 12px; margin-bottom: 10px; }
|
||||
.ipo-stock-name { font-size: 14px; font-weight: 600; color: #f0f0f0; display: flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; }
|
||||
.ipo-stock-name::-webkit-details-marker { display: none; }
|
||||
.ipo-stock-name::before { content: '▸'; color: #8b8f9a; font-size: 12px; flex: none; transition: transform .15s; }
|
||||
.ipo-stock[open] > .ipo-stock-name::before { transform: rotate(90deg); }
|
||||
.ipo-stock[open] > .ipo-stock-name { margin-bottom: 6px; }
|
||||
.ipo-sum-main { flex: 1; min-width: 0; }
|
||||
.ipo-sum-tags { display: flex; gap: 4px; flex: none; }
|
||||
.ipo-badge.done { background: rgba(126,226,168,0.12); color: #7ee2a8; }
|
||||
.ipo-item { border-top: 1px solid #1f2330; padding: 8px 0 4px; }
|
||||
.ipo-item-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
.ipo-broker { font-size: 13px; font-weight: 600; color: #cfd6e6; }
|
||||
.ipo-badge { font-size: 10px; padding: 1px 6px; border-radius: 999px; }
|
||||
.ipo-badge.last { background: rgba(255,77,94,0.15); color: #ff8a96; }
|
||||
.ipo-kv { display: grid; grid-template-columns: 52px 1fr; gap: 3px 8px; margin: 0; font-size: 12px; }
|
||||
.ipo-kv dt { color: #8b8f9a; }
|
||||
.ipo-kv dd { margin: 0; color: #d6d8dd; min-width: 0; overflow-wrap: anywhere; }
|
||||
.ipo-item-foot { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; }
|
||||
.ipo-act { position: relative; display: inline-block; }
|
||||
.btn-ipo-done { font-size: 12px; font-weight: 600; padding: 5px 12px; border-radius: 8px; cursor: pointer; font-family: inherit; background: #1e3a2c; color: #7ee2a8; border: 1px solid #2c5a41; }
|
||||
.btn-ipo-done:hover { background: #24473a; }
|
||||
.ipo-done { font-size: 12px; color: #8b8f9a; }
|
||||
.ipo-done.mine { color: #7ee2a8; font-weight: 600; }
|
||||
.ipo-stock.done .ipo-kv { margin-top: 2px; }
|
||||
/* 자산정보 탭 내부 sub-tab — 자산보기/차트보기. 상단 배치는 밑줄 강조, 하단 배치는 일반 버튼 형태. */
|
||||
.sub-tabs { display: flex; gap: 4px; margin: 8px 14px 0; border-bottom: 1px solid #1f2330; }
|
||||
.sub-tab { appearance: none; background: transparent; color: #8b8f9a; border: 0; border-bottom: 2px solid transparent; padding: 8px 14px; font-size: 13px; cursor: pointer; font-family: inherit; font-weight: 500; }
|
||||
@@ -7089,7 +7248,8 @@ table.adr-info-table td.adr-breakdown { font-size: 11px; color: #8b8f9a; white-s
|
||||
border-radius: 8px;
|
||||
background: #11141c;
|
||||
color: #c9ccd3;
|
||||
padding: 8px 18px;
|
||||
padding: 8px 4px;
|
||||
flex: 1 1 0; min-width: 0; max-width: 110px; white-space: nowrap; /* 4개(공모주 추가)가 320px 에서도 한 줄 */
|
||||
}
|
||||
.sub-tabs.sub-tabs-bottom .sub-tab:hover, .sub-tabs.sub-tabs-bottom .sub-tab:active { background: #1a1d26; color: #f0f0f0; }
|
||||
.sub-tabs.sub-tabs-bottom .sub-tab.active {
|
||||
@@ -8106,8 +8266,206 @@ def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _ipo_md(iso: str) -> str:
|
||||
"""'2026-10-02' → '10/02'. 빈 값은 ''."""
|
||||
return f'{iso[5:7]}/{iso[8:10]}' if iso and len(iso) >= 10 else ''
|
||||
|
||||
|
||||
def _ipo_period(start: str, end: str) -> str:
|
||||
if not start:
|
||||
return '미정'
|
||||
return _ipo_md(start) if not end or end == start else f'{_ipo_md(start)} ~ {_ipo_md(end)}'
|
||||
|
||||
|
||||
def _ipo_deposit_text(b: dict) -> str:
|
||||
"""ipo_alert.format_alert 의 증거금 문구와 같은 규칙."""
|
||||
if b.get('deposit') is not None and b.get('min_qty') is not None:
|
||||
return f"{b['deposit']:,}원 (최소 {b['min_qty']:,}주)"
|
||||
if b.get('price') is not None:
|
||||
return '최소청약수량 미확인'
|
||||
return '공모가 미반영' # 청약일엔 확정돼 있다 — 비어 있으면 네이버 반영 지연
|
||||
|
||||
|
||||
def _ipo_item_html(name: str, code: str, row: dict, b: dict, done: dict | None, today: str) -> str:
|
||||
"""증권사 1곳 = 아이템 1개. done = 그 종목의 청약완료 기록(있으면 버튼 대신 완료 표시)."""
|
||||
esc = html.escape
|
||||
broker = b.get('broker') or '미확인'
|
||||
po_s, po_e = row.get('poStartDate') or '', row.get('poEndDate') or ''
|
||||
lcal = row.get('lcalDate') or (done or {}).get('lcal_date') or ''
|
||||
fee = '미확인' if b.get('fee') is None else f"{b['fee']:,}원"
|
||||
badge = '<span class="ipo-badge last">마감일</span>' if po_e == today else ''
|
||||
by = (done or {}).get('by') or {}
|
||||
acts = []
|
||||
for who, label in IPO_WHO.items():
|
||||
rec = by.get(who)
|
||||
if rec:
|
||||
mine = rec.get('broker') == broker
|
||||
# 청약한 증권사 카드만 초록 강조, 나머지는 회색 — 증권사명은 청약완료 섹션에 있다.
|
||||
acts.append(f'<span class="ipo-done{" mine" if mine else ""}">✅ {label}(완료)</span>')
|
||||
continue
|
||||
acts.append(
|
||||
f'<span class="ipo-act" data-ipo-act data-ipo-code="{esc(code)}" data-ipo-who="{who}">'
|
||||
f'<button type="button" class="btn-ipo-done" data-ipo-done data-ipo-code="{esc(code)}" data-ipo-who="{who}"'
|
||||
f' data-ipo-who-label="{label}" data-ipo-broker="{esc(broker)}" data-ipo-name="{esc(name)}">{label}(청약)</button>'
|
||||
'<span class="note-confirm hidden" data-ipo-confirm>'
|
||||
f'<span class="note-confirm-msg">{label} — {esc(broker)}에서 청약하셨나요?</span>'
|
||||
'<button type="button" class="note-confirm-yes" data-ipo-confirm-yes>예</button>'
|
||||
'<button type="button" class="note-confirm-no" data-ipo-confirm-no>아니오</button>'
|
||||
'</span></span>'
|
||||
)
|
||||
act = ''.join(acts)
|
||||
return (
|
||||
f'<div class="ipo-item" data-ipo-code="{esc(code)}">'
|
||||
f'<div class="ipo-item-head"><span class="ipo-broker">{esc(broker)}</span>{badge}</div>'
|
||||
'<dl class="ipo-kv">'
|
||||
f'<dt>청약일</dt><dd>{esc(_ipo_period(po_s, po_e))}</dd>'
|
||||
f'<dt>증거금</dt><dd>{esc(_ipo_deposit_text(b))}</dd>'
|
||||
f'<dt>수수료</dt><dd>{esc(fee)} <span class="ipo-sub">(온라인 일반)</span></dd>'
|
||||
f'<dt>상장일</dt><dd>{esc(_ipo_md(lcal) or "미정")}</dd>'
|
||||
'</dl>'
|
||||
f'<div class="ipo-item-foot">{act}</div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
def register_listed_ipo_interests(target_iso: str, listed_codes: set[str]) -> list[str]:
|
||||
"""청약완료한 종목을 상장일에 관심종목으로 등록. ipo_alert.py listing(평일 08:30)이 부른다.
|
||||
대상 = 청약완료 기록 중 상장일이 target 인 것(API 최신 상장일 목록 listed_codes 또는 기록의 lcal_date).
|
||||
종목당 1회 — 성공(또는 이미 관심종목)하면 기록에 interest_added 를 남긴다. 등록한 종목명 목록 반환."""
|
||||
added = []
|
||||
for code, rec in _load_ipo_subscribed().items():
|
||||
if rec.get('interest_added'):
|
||||
continue
|
||||
if code not in listed_codes and rec.get('lcal_date') != target_iso:
|
||||
continue
|
||||
name = rec.get('name') or ''
|
||||
stock_code = code[1:] if code.startswith('A') else code # ipoCode 'A179880' → 종목코드 '179880'
|
||||
try:
|
||||
who_txt = ', '.join(f'{IPO_WHO.get(w, w)} {v.get("broker") or ""}' for w, v in (rec.get('by') or {}).items())
|
||||
_apply_interests_action('add', name, {'code': stock_code, 'memo': f'공모주 청약 ({who_txt})'})
|
||||
added.append(name)
|
||||
except ValueError as e:
|
||||
if '이미 등록된' not in str(e):
|
||||
sys.stderr.write(f'ipo interest add failed {name}: {e}\n')
|
||||
continue
|
||||
with _ipo_subscribed_lock():
|
||||
d = _load_ipo_subscribed()
|
||||
if code in d:
|
||||
d[code]['interest_added'] = datetime.now(KST).isoformat(timespec='seconds')
|
||||
tmp = IPO_SUBSCRIBED.with_suffix('.json.tmp')
|
||||
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
|
||||
tmp.replace(IPO_SUBSCRIBED)
|
||||
return added
|
||||
|
||||
|
||||
def _ipo_flag() -> tuple[list[str], str]:
|
||||
"""타이틀 '자산현황' 옆 배지 목록과 툴팁 사유. 목록이 비면 숨김.
|
||||
조건(관리자님 지정): ①청약기간 중인데 아직 청약완료 안 한 종목 ②청약완료한 종목의 오늘 상장."""
|
||||
try:
|
||||
today = datetime.now(KST).date().isoformat()
|
||||
rows = _fetch_ipo_rows() or []
|
||||
subscribed = _load_ipo_subscribed()
|
||||
pending = sum(
|
||||
1 for r in rows
|
||||
if r.get('poStartDate') and r['poStartDate'] <= today <= (r.get('poEndDate') or '')
|
||||
and not (subscribed.get((r.get('ipoCode') or r.get('itemCode') or '').strip()) or {}).get('by')
|
||||
)
|
||||
by_code = {(r.get('ipoCode') or r.get('itemCode') or '').strip(): r for r in rows}
|
||||
listing = sum(
|
||||
1 for code, rec in subscribed.items()
|
||||
if ((by_code.get(code) or {}).get('lcalDate') or rec.get('lcal_date')) == today
|
||||
)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'ipo flag failed: {type(e).__name__}: {e}\n')
|
||||
return [], ''
|
||||
# 배지는 사유와 무관하게 [청약] 하나(관리자님 지정). 사유는 툴팁으로만 구분.
|
||||
parts = []
|
||||
if pending:
|
||||
parts.append(f'미완료 청약 {pending}건')
|
||||
if listing:
|
||||
parts.append(f'오늘 상장 {listing}건')
|
||||
return (['청약'] if parts else []), ' · '.join(parts)
|
||||
|
||||
|
||||
def _render_ipo_panel() -> str:
|
||||
"""자산정보 > 공모주 sub-tab. ① 오늘청약(청약기간 중 종목 × 증권사) ② 청약완료(상장일까지).
|
||||
네이버 IPO API(30분 캐시) + 청약기간 중·완료 종목만 상세 API. 키움 콜 0."""
|
||||
from ipo_alert import _manager_blocks
|
||||
esc = html.escape
|
||||
today = datetime.now(KST).date().isoformat()
|
||||
rows = _fetch_ipo_rows()
|
||||
subscribed = _load_ipo_subscribed()
|
||||
if rows is None:
|
||||
return '<div class="market-card market-card-error">공모주 정보를 불러올 수 없습니다.</div>'
|
||||
by_code = {(r.get('ipoCode') or r.get('itemCode') or '').strip(): r for r in rows}
|
||||
|
||||
# ① 오늘청약 — 청약기간 중인 종목 전부(1일차·2일차 모두)
|
||||
live = sorted(
|
||||
(r for r in rows if (r.get('poStartDate') or '') <= today <= (r.get('poEndDate') or '') and r.get('poStartDate')),
|
||||
key=lambda r: (r.get('poEndDate') or '', r.get('compName') or ''),
|
||||
)
|
||||
today_parts = []
|
||||
for r in live:
|
||||
code = (r.get('ipoCode') or r.get('itemCode') or '').strip()
|
||||
name = (r.get('compName') or '').strip()
|
||||
blocks = _manager_blocks(r, _ipo_detail_cached(code) or {})
|
||||
items = ''.join(_ipo_item_html(name, code, r, b, subscribed.get(code), today) for b in blocks)
|
||||
tags = ''
|
||||
if (r.get('poEndDate') or '') == today:
|
||||
tags += '<span class="ipo-badge last">마감일</span>'
|
||||
for who in (subscribed.get(code) or {}).get('by') or {}:
|
||||
tags += f'<span class="ipo-badge done">✅ {IPO_WHO.get(who, who)}</span>'
|
||||
today_parts.append(
|
||||
f'<details class="ipo-stock" data-ipo-key="t:{esc(code)}"><summary class="ipo-stock-name">'
|
||||
f'<span class="ipo-sum-main">{esc(name)}<span class="ipo-sub"> {esc(r.get("marketType") or "")}'
|
||||
f' · {esc(_ipo_period(r.get("poStartDate") or "", r.get("poEndDate") or ""))} · {len(blocks)}곳</span></span>'
|
||||
f'<span class="ipo-sum-tags">{tags}</span></summary>{items}</details>'
|
||||
)
|
||||
today_html = ''.join(today_parts) or '<div class="ipo-empty">오늘 청약 중인 공모주가 없습니다.</div>'
|
||||
|
||||
# ② 청약완료 — 상장일이 지나면 숨김(미정이면 계속 표시). 상장일은 API 최신값 우선.
|
||||
done_parts = []
|
||||
for code, rec in sorted(subscribed.items(), key=lambda kv: kv[1].get('po_start') or ''):
|
||||
if not rec.get('by'):
|
||||
continue
|
||||
r = by_code.get(code) or {}
|
||||
lcal = r.get('lcalDate') or rec.get('lcal_date') or ''
|
||||
if lcal and lcal < today:
|
||||
continue
|
||||
r = {**{'compName': rec.get('name'), 'poStartDate': rec.get('po_start'),
|
||||
'poEndDate': rec.get('po_end'), 'lcalDate': lcal}, **{k: v for k, v in r.items() if v}}
|
||||
blocks = _manager_blocks(r, _ipo_detail_cached(code) or {})
|
||||
who_rows = ''
|
||||
for who, v in rec['by'].items():
|
||||
b = next((x for x in blocks if x.get('broker') == v.get('broker')), {'broker': v.get('broker')})
|
||||
who_rows += (f'<dt>{IPO_WHO.get(who, who)}</dt><dd>{esc(v.get("broker") or "")} · {esc(_ipo_deposit_text(b))}'
|
||||
f' <span class="ipo-sub">{esc((v.get("at") or "")[5:16].replace("T", " "))}</span></dd>')
|
||||
done_parts.append(
|
||||
f'<details class="ipo-stock done" data-ipo-key="d:{esc(code)}"><summary class="ipo-stock-name">'
|
||||
f'<span class="ipo-sum-main">{esc(rec.get("name") or code)}'
|
||||
f'<span class="ipo-sub"> · {esc("·".join(IPO_WHO.get(w, w) for w in rec["by"]))} · 상장 {esc(_ipo_md(lcal) or "미정")}</span></span>'
|
||||
'</summary>'
|
||||
'<dl class="ipo-kv">'
|
||||
+ who_rows +
|
||||
f'<dt>청약일</dt><dd>{esc(_ipo_period(r.get("poStartDate") or "", r.get("poEndDate") or ""))}</dd>'
|
||||
f'<dt>환불일</dt><dd>{esc(_ipo_md(r.get("refundDate") or "") or "미정")}</dd>'
|
||||
f'<dt>상장일</dt><dd>{esc(_ipo_md(lcal) or "미정")}</dd>'
|
||||
'</dl></details>'
|
||||
)
|
||||
done_html = ''.join(done_parts) or '<div class="ipo-empty">청약완료한 종목이 없습니다.</div>'
|
||||
|
||||
return (
|
||||
'<div class="ipo-card">'
|
||||
f'<div class="ipo-sec-title">오늘청약 <span class="ipo-sub">{esc(_ipo_md(today))}</span></div>'
|
||||
+ today_html
|
||||
+ '<div class="ipo-sec-title">청약완료 <span class="ipo-sub">상장일까지 표시</span></div>'
|
||||
+ done_html
|
||||
+ '</div>'
|
||||
)
|
||||
|
||||
|
||||
def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances: dict | None = None, journal_by_label: dict | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> str:
|
||||
"""자산정보 탭 — sub-tab(자산보기/차트보기/시장정보)로 분리.
|
||||
"""자산정보 탭 — sub-tab(자산보기/차트보기/시장정보/공모주)로 분리.
|
||||
- 자산보기(기본): owner별 compact KPI 카드 스택 (합산 / 각계좌별도 토글)
|
||||
- 차트보기: 기간·단위·모드 select + owner별 차트 (순자산 / 손익누적 토글)
|
||||
- 시장정보: 오늘의 ADR·투자자별 매매 카드 + ADR 추세 sparkline (기간 select, 라이브 포인트)
|
||||
@@ -8202,6 +8560,11 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
|
||||
|
||||
market_card = _render_market_indicators_card()
|
||||
adr_trend_card = _render_adr_trend_card(days=adr_days)
|
||||
try:
|
||||
ipo_panel = _render_ipo_panel()
|
||||
except Exception as e: # 공모주 쪽 오류가 자산정보 탭 전체를 깨뜨리지 않게
|
||||
sys.stderr.write(f'ipo panel render failed: {type(e).__name__}: {e}\n')
|
||||
ipo_panel = '<div class="market-card market-card-error">공모주 정보를 불러올 수 없습니다.</div>'
|
||||
# 자산보기 sub-panel 안 합산/각계좌별도 panes (양쪽 prerender). 토글 버튼은 페이지 상단(자동 토글 옆) 1곳으로 통일.
|
||||
assets_inner = (
|
||||
'<div class="account-view-pane" data-account-view-pane="consolidated">'
|
||||
@@ -8222,10 +8585,14 @@ def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances:
|
||||
+ market_card
|
||||
+ adr_trend_card
|
||||
+ '</div>'
|
||||
'<div class="sub-tab-panel" data-sub-panel="ipo" role="tabpanel" hidden>'
|
||||
+ ipo_panel
|
||||
+ '</div>'
|
||||
'<div class="sub-tabs sub-tabs-bottom" role="tablist">'
|
||||
'<button type="button" class="sub-tab active" data-sub-tab="assets" role="tab" aria-selected="true">자산보기</button>'
|
||||
'<button type="button" class="sub-tab" data-sub-tab="chart" role="tab" aria-selected="false">차트보기</button>'
|
||||
'<button type="button" class="sub-tab" data-sub-tab="market" role="tab" aria-selected="false">시장정보</button>'
|
||||
'<button type="button" class="sub-tab" data-sub-tab="ipo" role="tab" aria-selected="false">공모주</button>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
@@ -9097,6 +9464,7 @@ def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_
|
||||
'tabs': tabs,
|
||||
'indices': indices,
|
||||
'ticker_items': indices,
|
||||
'ipo_flag': _ipo_flag(),
|
||||
}
|
||||
|
||||
|
||||
@@ -9264,6 +9632,10 @@ def render_html() -> str:
|
||||
'}'
|
||||
'setMarket(!!p.market_active);'
|
||||
'setTicker(p.ticker_items||p.indices||[]);'
|
||||
# 타이틀 🎫 — 미완료 청약·청약 종목 오늘 상장이 있을 때만. 사유는 title 툴팁.
|
||||
'if(p.ipo_flag){var ipf=document.getElementById("ipo-flag");if(ipf){var ipb=p.ipo_flag[0]||[];'
|
||||
'ipf.hidden=!ipb.length;ipf.title=p.ipo_flag[1]||"";'
|
||||
'ipf.innerHTML=ipb.map(function(x){return \'<span class="ipo-flag-b">\'+x+\'</span>\';}).join("");}}'
|
||||
'var byId={};(p.tabs||[]).forEach(function(t){'
|
||||
'byId[t.id]=t;'
|
||||
# partial 응답이라도 자기 탭만 fetched_at 갱신 — 다른 탭 값은 그대로.
|
||||
@@ -9281,7 +9653,13 @@ def render_html() -> str:
|
||||
'sec.querySelectorAll("details.row[open][data-row-key]").forEach(function(d){'
|
||||
'openKeys[d.getAttribute("data-row-key")]=1;'
|
||||
'});'
|
||||
# 공모주 카드 펼침도 swap(청약완료 후 재로드 등)에서 유지.
|
||||
'var ipoOpen=[];'
|
||||
'sec.querySelectorAll("details.ipo-stock[open][data-ipo-key]").forEach(function(d){ipoOpen.push(d.getAttribute("data-ipo-key"));});'
|
||||
'sec.innerHTML=t.html;'
|
||||
'ipoOpen.forEach(function(k){'
|
||||
'var d=sec.querySelector(\'details.ipo-stock[data-ipo-key="\'+k+\'"]\');if(d)d.setAttribute("open","");'
|
||||
'});'
|
||||
'Object.keys(openKeys).forEach(function(k){'
|
||||
# 그룹 탭에서 같은 종목이 전체·그룹 패널에 중복 존재 → 매칭되는 모든 행 복원.
|
||||
'sec.querySelectorAll(\'details.row[data-row-key="\'+k.replace(/"/g,\'\\\\"\')+\'"]\').forEach(function(d){d.setAttribute("open","");});'
|
||||
@@ -11759,6 +12137,52 @@ def render_html() -> str:
|
||||
'openModal("note-modal");'
|
||||
'return;'
|
||||
'}'
|
||||
# 공모주 청약완료 — 버튼 → 확인 팝업(예/아니오) → POST. 성공하면 같은 종목·같은 사람의 모든
|
||||
# 증권사 버튼을 즉시 완료 표시로 바꾸고, 청약완료 섹션 반영을 위해 패널을 fresh 로 다시 받는다.
|
||||
'var ipoBtn=t.closest&&t.closest("[data-ipo-done]");'
|
||||
'if(ipoBtn){'
|
||||
'e.preventDefault();e.stopPropagation();'
|
||||
'document.querySelectorAll("[data-ipo-confirm]").forEach(function(p){p.classList.add("hidden");});'
|
||||
'var ipop=ipoBtn.parentNode.querySelector("[data-ipo-confirm]");if(ipop)ipop.classList.remove("hidden");'
|
||||
'return;'
|
||||
'}'
|
||||
'var ipoNo=t.closest&&t.closest("[data-ipo-confirm-no]");'
|
||||
'if(ipoNo){'
|
||||
'e.preventDefault();e.stopPropagation();'
|
||||
'ipoNo.closest("[data-ipo-confirm]").classList.add("hidden");'
|
||||
'return;'
|
||||
'}'
|
||||
'var ipoYes=t.closest&&t.closest("[data-ipo-confirm-yes]");'
|
||||
'if(ipoYes){'
|
||||
'e.preventDefault();e.stopPropagation();'
|
||||
'var iAct=ipoYes.closest("[data-ipo-act]");var iB=iAct?iAct.querySelector("[data-ipo-done]"):null;'
|
||||
'if(!iB||iB.dataset.busy==="1")return;'
|
||||
'iB.dataset.busy="1";ipoYes.closest("[data-ipo-confirm]").classList.add("hidden");'
|
||||
'var iCode=iB.dataset.ipoCode||"",iBroker=iB.dataset.ipoBroker||"",iName=iB.dataset.ipoName||"";'
|
||||
'var iWho=iB.dataset.ipoWho||"",iWhoL=iB.dataset.ipoWhoLabel||"";'
|
||||
'var ib=new URLSearchParams();ib.append("code",iCode);ib.append("broker",iBroker);ib.append("who",iWho);'
|
||||
'fetch("/ipo/subscribe",{method:"POST",headers:{"Accept":"application/json","Content-Type":"application/x-www-form-urlencoded"},body:ib.toString(),credentials:"same-origin"})'
|
||||
'.then(function(r){return r.json().catch(function(){return {ok:false,error:"HTTP "+r.status};});})'
|
||||
'.then(function(j){'
|
||||
'if(j&&j.ok){'
|
||||
'var sb=(j.record&&j.record.broker)||iBroker;'
|
||||
'document.querySelectorAll("[data-ipo-act][data-ipo-code=\\""+iCode+"\\"][data-ipo-who=\\""+iWho+"\\"]").forEach(function(a){'
|
||||
'var br=(a.querySelector("[data-ipo-done]")||{}).dataset||{};'
|
||||
'var mine=br.ipoBroker===sb;'
|
||||
'var sp=document.createElement("span");sp.className="ipo-done"+(mine?" mine":"");'
|
||||
'sp.textContent="✅ "+iWhoL+"(완료)";'
|
||||
'a.replaceWith(sp);'
|
||||
'});'
|
||||
'showToast(iName+" "+iWhoL+" 청약완료로 기록했습니다");'
|
||||
'if(window.__behive_load)window.__behive_load(null,true);'
|
||||
'}else{'
|
||||
'showToast("청약완료 기록 실패: "+((j&&j.error)||"오류"));'
|
||||
'}'
|
||||
'})'
|
||||
'.catch(function(){showToast("네트워크 오류");})'
|
||||
'.finally(function(){iB.dataset.busy="";});'
|
||||
'return;'
|
||||
'}'
|
||||
# 급등락 알림 토글 — 낙관적 갱신(즉시 반영) 후 POST. 실패하면 되돌린다.
|
||||
# ⚠️ 패널 전체 새로고침(__behive_load)을 부르지 않는다 — 키움 조회까지 다시 돌아
|
||||
# 버튼이 몇 초 뒤에야 바뀐다. 토글 말고 달라지는 게 없어서 DOM만 고치면 충분하고,
|
||||
@@ -14355,7 +14779,7 @@ window.openPinModal = openPinModal;
|
||||
<div class="topbar">
|
||||
<header class="top">
|
||||
<div class="titles">
|
||||
<h1>자산현황</h1>
|
||||
<h1>자산현황<span id="ipo-flag" class="ipo-flag" hidden></span></h1>
|
||||
<span class="meta">갱신 {now}</span>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
@@ -16288,6 +16712,26 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
if self.path == '/ipo/subscribe':
|
||||
# 공모주 청약완료 기록 — 사람(who)별 종목당 1건(중복청약 금지). 이미 기록돼 있으면 기존 기록을 돌려준다.
|
||||
code = (params.get('code') or [''])[0].strip()
|
||||
broker = (params.get('broker') or [''])[0].strip()
|
||||
who = (params.get('who') or [''])[0].strip()
|
||||
try:
|
||||
rec = _set_ipo_subscribed(code, broker, who)
|
||||
_invalidate_panels_cache()
|
||||
except ValueError as e:
|
||||
self._send_json(400, {'ok': False, 'error': str(e)})
|
||||
return
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self._send_json(500, {'ok': False, 'error': str(e)})
|
||||
return
|
||||
sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} code={code} broker={broker} who={who} → ok\n')
|
||||
sys.stdout.flush()
|
||||
self._send_json(200, {'ok': True, 'record': rec})
|
||||
return
|
||||
|
||||
if self.path == '/surge/toggle':
|
||||
# 급등락 알림 on/off. code 필수 (모니터가 코드로 시세를 조회하므로 name fallback 없음).
|
||||
# on 파라미터 없으면 현재 상태를 뒤집는다.
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,68 @@
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import ipo_alert
|
||||
|
||||
|
||||
class IpoAlertTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.row = {
|
||||
'ipoCode': 'A250030',
|
||||
'compName': '진코스텍',
|
||||
'poStartDate': '2026-10-02',
|
||||
'poEndDate': '2026-10-06',
|
||||
'lcalDate': '2026-10-15',
|
||||
'fixPubPrice': '23500',
|
||||
'orgNm': '하나증권',
|
||||
}
|
||||
self.detail = {
|
||||
'ipoInfo': {
|
||||
'compName': '진코스텍',
|
||||
'poStartDate': '2026-10-02',
|
||||
'poEndDate': '2026-10-06',
|
||||
'lcalDate': '2026-10-15',
|
||||
},
|
||||
'registrationStatement': {'sbscMrgnRatio': '50.00'},
|
||||
'demandForecastingResult': {'fixPubPrice': '23500'},
|
||||
'joinManagers': [{'orgNm': '하나증권', 'sbscMinNum': '10'}],
|
||||
}
|
||||
|
||||
def test_subscription_format_uses_first_day_and_minimum_cash(self):
|
||||
text = ipo_alert.format_alert('subscription', self.row, self.detail)
|
||||
self.assertIn('종목명 : 진코스텍', text)
|
||||
self.assertIn('증권사 : 하나증권', text)
|
||||
self.assertIn('청약일 : 2026-10-02 ~ 2026-10-06', text)
|
||||
self.assertIn('증거금 : 117,500원(최소 10주)', text)
|
||||
self.assertNotIn('23,500원', text)
|
||||
self.assertNotIn('50%', text)
|
||||
self.assertIn('수수료 2,000원(온라인 일반 기준)', text)
|
||||
|
||||
def test_listing_uses_listing_date(self):
|
||||
text = ipo_alert.format_alert('listing', self.row, self.detail)
|
||||
self.assertIn('[신규 상장]', text)
|
||||
self.assertIn('상장일 : 2026-10-15', text)
|
||||
|
||||
def test_collect_subscription_matches_only_first_day(self):
|
||||
payload = {
|
||||
'examinationList': [], 'demandForecastingList': [],
|
||||
'forecastingCompleteList': [],
|
||||
'subscriptionList': [self.row],
|
||||
'subscriptionCompleteList': [], 'listingList': [],
|
||||
}
|
||||
with patch.object(ipo_alert, 'fetch', return_value=ipo_alert.json.dumps(payload)), patch.object(
|
||||
ipo_alert, '_detail', return_value=self.detail
|
||||
):
|
||||
first = ipo_alert.collect_for_date('subscription', date(2026, 10, 2))
|
||||
second = ipo_alert.collect_for_date('subscription', date(2026, 10, 3))
|
||||
self.assertEqual(len(first), 1)
|
||||
self.assertEqual(second, [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user