자산웹 공모주 탭 추가 + 상장일 관심종목 자동등록

- 자산정보 > 공모주 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:
hyowons
2026-10-02 12:32:15 +09:00
parent b404af9020
commit 5e47a15547
6 changed files with 761 additions and 13 deletions
+3 -1
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -95,6 +95,7 @@ LLM-LLM 자연어 통신은 프롬프트 인젝션·할루시네이션 증폭
- **키움 조회:** `scripts/kiwoom_client.py {token|accounts|summary|balance|positions}` — 조회 전용 (주문 X) - **키움 조회:** `scripts/kiwoom_client.py {token|accounts|summary|balance|positions}` — 조회 전용 (주문 X)
- **일일 포트폴리오:** `scripts/stock_portfolio_report.py {run|send} [--by-account]` — 평일 20:10 launchd, 메일만 발송(텔레그램 요약 없음) - **일일 포트폴리오:** `scripts/stock_portfolio_report.py {run|send} [--by-account]` — 평일 20:10 launchd, 메일만 발송(텔레그램 요약 없음)
- **IPO 캘린더:** `scripts/ipo_calendar_sync.py` — 매월 1일 cron - **IPO 캘린더:** `scripts/ipo_calendar_sync.py` — 매월 1일 cron
- **IPO 텔레그램 알림:** `scripts/ipo_alert.py` — 매일 08:30 상장일, 10:00 청약 첫날만 알림. 증권사별 최소청약 증거금·온라인 일반 수수료 표시(공모가·증거금률은 숨김), `state/ipo_alerts.json`으로 중복 방지. 상장일(08:30 listing) 실행 때 자산웹에서 **청약완료한 종목은 관심종목에 자동등록**(종목당 1회)
- **비하이브 다이제스트:** `scripts/behive_youtube_digest.py {fetch|save|email|notify|list|show|remove}` — 평일 07/12/18시 cron - **비하이브 다이제스트:** `scripts/behive_youtube_digest.py {fetch|save|email|notify|list|show|remove}` — 평일 07/12/18시 cron
- **워치리스트 모니터:** `scripts/watchlist_monitor.py check` — 장중 15분, LLM 없이 직접 알림 - **워치리스트 모니터:** `scripts/watchlist_monitor.py check` — 장중 15분, LLM 없이 직접 알림
- **워치리스트 웹뷰:** `scripts/behive_web.py serve` — launchd 상시, 외부는 `https://stock.hyowons.net/` - **워치리스트 웹뷰:** `scripts/behive_web.py serve` — launchd 상시, 외부는 `https://stock.hyowons.net/`
@@ -136,6 +137,7 @@ LLM-LLM 자연어 통신은 프롬프트 인젝션·할루시네이션 증폭
- `scripts/kiwoom_client.py {token|accounts|summary|balance|positions}` — 키움 REST 조회 전용 클라이언트. 잔고/보유/계좌평가. **주문 함수 절대 부재**(매매 절대 원칙, SOUL.md 참조). 자세한 사용법은 `skills/kiwoom-rest`. - `scripts/kiwoom_client.py {token|accounts|summary|balance|positions}` — 키움 REST 조회 전용 클라이언트. 잔고/보유/계좌평가. **주문 함수 절대 부재**(매매 절대 원칙, SOUL.md 참조). 자세한 사용법은 `skills/kiwoom-rest`.
- `scripts/stock_portfolio_report.py {run|send} [--by-account]` — 리포트 생성/이메일. 평일 20:10 launchd로 `send`. owner 그룹(본인 / 가희)별 블록 표시가 기본 (그룹 내 종목 합산), `--by-account`는 owner 블록 안에서 4계좌별 분리 뷰까지. - `scripts/stock_portfolio_report.py {run|send} [--by-account]` — 리포트 생성/이메일. 평일 20:10 launchd로 `send`. owner 그룹(본인 / 가희)별 블록 표시가 기본 (그룹 내 종목 합산), `--by-account`는 owner 블록 안에서 4계좌별 분리 뷰까지.
- `scripts/ipo_calendar_sync.py` — 공모주 일정 구글 캘린더 등록. 매월 1일 cron. - `scripts/ipo_calendar_sync.py` — 공모주 일정 구글 캘린더 등록. 매월 1일 cron.
- `scripts/ipo_alert.py {subscription|listing}` — 청약 첫날 10:00·상장일 08:30 텔레그램 알림. `--dry-run`, `--date YYYY-MM-DD` 지원.
- `scripts/behive_youtube_digest.py {fetch|save|email|notify|list|show|remove}` — 비하이브투자자문 '종목분석' 영상 감지·요약·워치리스트 저장·이메일/텔레그램 발송. 평일 07/12/18시 cron. - `scripts/behive_youtube_digest.py {fetch|save|email|notify|list|show|remove}` — 비하이브투자자문 '종목분석' 영상 감지·요약·워치리스트 저장·이메일/텔레그램 발송. 평일 07/12/18시 cron.
## 관리자님 관련 ## 관리자님 관련
+1 -1
View File
@@ -52,7 +52,7 @@
| `ipo-calendar-sync` | 매주 금요일 17:00 | `ipo_calendar_sync.py` | IPO 청약·상장 일정 → Google Calendar | | `ipo-calendar-sync` | 매주 금요일 17:00 | `ipo_calendar_sync.py` | IPO 청약·상장 일정 → Google Calendar |
| `fomc-calendar-sync` | 매월 1일 09:10 | `fomc_calendar_sync.py` | 연준 FOMC 회의 일정 → Google Calendar `[FOMC] YYYY-MM 회의`(+` (SEP)`). 2026-07-29 신설. **캘린더엔 2026-03-23 관리자님이 2027-12까지 11건을 이미 손으로 등록해 두셨고 날짜도 정확** — 스크립트는 그 표기를 승계해 흡수했으므로 당분간 실제 신규 등록은 2028년분부터. **이벤트 기간 = 회의 시작일(ET) ~ 발표일(KST)** 3일 — 성명 발표가 KST로는 회의 종료 다음날 새벽(EDT 03:00·EST 04:00)이라, ET 날짜만 넣으면 한국 장이 결과를 반영하는 날에 캘린더가 비어 브리핑 '오늘 일정'에 안 뜬다(2026-07-29 관리자님 지적으로 수정). colorId 9 보존(`--event-color` 미지정). 삭제 루프가 `old_start <= today`를 건너뛰어 지난 회의는 안 지움. `--list`=파싱만(gog 미호출) | | `fomc-calendar-sync` | 매월 1일 09:10 | `fomc_calendar_sync.py` | 연준 FOMC 회의 일정 → Google Calendar `[FOMC] YYYY-MM 회의`(+` (SEP)`). 2026-07-29 신설. **캘린더엔 2026-03-23 관리자님이 2027-12까지 11건을 이미 손으로 등록해 두셨고 날짜도 정확** — 스크립트는 그 표기를 승계해 흡수했으므로 당분간 실제 신규 등록은 2028년분부터. **이벤트 기간 = 회의 시작일(ET) ~ 발표일(KST)** 3일 — 성명 발표가 KST로는 회의 종료 다음날 새벽(EDT 03:00·EST 04:00)이라, ET 날짜만 넣으면 한국 장이 결과를 반영하는 날에 캘린더가 비어 브리핑 '오늘 일정'에 안 뜬다(2026-07-29 관리자님 지적으로 수정). colorId 9 보존(`--event-color` 미지정). 삭제 루프가 `old_start <= today`를 건너뛰어 지난 회의는 안 지움. `--list`=파싱만(gog 미호출) |
| `holiday-sync` | 매주 일요일 03:00 | `holiday_sync.py` | investing.com → `state/market_holidays.json`. 실패 시 기존 파일 보존 | | `holiday-sync` | 매주 일요일 03:00 | `holiday_sync.py` | investing.com → `state/market_holidays.json`. 실패 시 기존 파일 보존 |
| `behive-web` | 상시 (`KeepAlive`) | `behive_web.py serve` | 3-탭 워치리스트 웹뷰. Tailscale `100.75.148.12:18790` → `https://stock.hyowons.net/`(Synology reverse proxy). 코드 변경 후 bootout/bootstrap 필요 | | `behive-web` | 상시 (`KeepAlive`) | `behive_web.py serve` | 3-탭 워치리스트 웹뷰. Tailscale `100.75.148.12:18790` → `https://stock.hyowons.net/`(Synology reverse proxy). 코드 변경 후 bootout/bootstrap 필요. **공모주 sub-tab**(2026-10-02): 자산정보 탭에서 청약기간 중 종목을 증권사별로 보여주고 `[관리자(청약)] [가희(청약)]`→완료 시 `관리자(완료)`로 사람별 종목당 1건 기록(`state/behive_ipo_subscribed.json`, 상장일까지 표시). 증거금 계산은 `ipo_alert._manager_blocks` 재사용. 청약완료 종목은 상장일 08:30 `ipo-alert`(listing) 실행 때 관심종목 자동등록(`register_listed_ipo_interests`, 종목당 1회) |
| `trade-journal` | 평일 21:00 (스크립트가 휴장일 self-skip) | `trade_journal.py collect` | ka10170 당일매매일지 4계좌 → `state/trade_journal.jsonl` 누적. NXT 야간 마감 후 발화. 키움이 기간 거래내역 API 미제공이라 일자별 적재만이 유일. `briefing-fallback-2100`(21:00)과 같은 시각이지만 둘 다 read-only 충돌 없음. 재실행 시 (date,account) 단위 idempotent. **시드**: 2026-05-13 1회 `trade_journal.py seed` 로 적재 시작일 이전 보유분을 현재 평단가×(qty - tdy_buyq + tdy_sellq)로 단일 시드 행 압축(28건, seed=true 플래그, `*` 마커). 시드는 가중평균이라 과거 매수 단가와 정확히 일치 X. CLI: `collect`/`seed`/`show <code>`/`query --from --to --account --code`. **2026-06-15 4번째 명령 추가**: `python3 -m sim universe all` — sim 관찰목록 자동편입/제외. 키움 순위정보(ka90009 외인·기관순매수 + ka10023 거래량급증)에서 ETF·하락·급증률 이상치 거르고 각 15·총 120 상한 편입(origin='auto'), origin=auto·미보유·10거래일 무신호만 자동제외(보유/watch/interest/manual 불가침). 신호추적=scan_all `universe.mark_signals`→`auto_seen.json`. **2026-09-10 5번째 명령 추가**: `kiwoom_client.py refresh-codes --if-stale 7` — 종목코드 캐시(`state/stock_codes.json`) 주 1회 갱신. `--if-stale N` 이 파일 mtime 을 보고 N일 미만이면 건너뛰므로 매일 발화해도 실제 ka10099 조회는 주 1회. ⚠️ 이 캐시의 `nxt_enable` 이 자산웹의 NXT 거래 종목 판정 근거(`behive_web._is_nxt_traded`)라 낡으면 🔥거래량 배지·거래가능 초록점이 틀어진다(금액·매매엔 영향 없음). 기존 lazy 갱신은 '모르는 종목명 검색' 때만 돌아 8/20~9/10 3주간 방치돼 있었고, 갱신하니 4300종목 중 nxt_enable 3건 변경·신규 17·삭제 13이 실제로 나왔다 | | `trade-journal` | 평일 21:00 (스크립트가 휴장일 self-skip) | `trade_journal.py collect` | ka10170 당일매매일지 4계좌 → `state/trade_journal.jsonl` 누적. NXT 야간 마감 후 발화. 키움이 기간 거래내역 API 미제공이라 일자별 적재만이 유일. `briefing-fallback-2100`(21:00)과 같은 시각이지만 둘 다 read-only 충돌 없음. 재실행 시 (date,account) 단위 idempotent. **시드**: 2026-05-13 1회 `trade_journal.py seed` 로 적재 시작일 이전 보유분을 현재 평단가×(qty - tdy_buyq + tdy_sellq)로 단일 시드 행 압축(28건, seed=true 플래그, `*` 마커). 시드는 가중평균이라 과거 매수 단가와 정확히 일치 X. CLI: `collect`/`seed`/`show <code>`/`query --from --to --account --code`. **2026-06-15 4번째 명령 추가**: `python3 -m sim universe all` — sim 관찰목록 자동편입/제외. 키움 순위정보(ka90009 외인·기관순매수 + ka10023 거래량급증)에서 ETF·하락·급증률 이상치 거르고 각 15·총 120 상한 편입(origin='auto'), origin=auto·미보유·10거래일 무신호만 자동제외(보유/watch/interest/manual 불가침). 신호추적=scan_all `universe.mark_signals`→`auto_seen.json`. **2026-09-10 5번째 명령 추가**: `kiwoom_client.py refresh-codes --if-stale 7` — 종목코드 캐시(`state/stock_codes.json`) 주 1회 갱신. `--if-stale N` 이 파일 mtime 을 보고 N일 미만이면 건너뛰므로 매일 발화해도 실제 ka10099 조회는 주 1회. ⚠️ 이 캐시의 `nxt_enable` 이 자산웹의 NXT 거래 종목 판정 근거(`behive_web._is_nxt_traded`)라 낡으면 🔥거래량 배지·거래가능 초록점이 틀어진다(금액·매매엔 영향 없음). 기존 lazy 갱신은 '모르는 종목명 검색' 때만 돌아 8/20~9/10 3주간 방치돼 있었고, 갱신하니 4300종목 중 nxt_enable 3건 변경·신규 17·삭제 13이 실제로 나왔다 |
| `sim-scan` | 평일 09:00–15:30 **매 2분** (`StartCalendarInterval` 196엔트리, 엔진이 장외/휴장 self-skip) ⚠️2026-06-09 15분→5분, 2026-06-17 5분→2분 단축(스캔 1회 실측 ~3.6초라 2분 cadence 오버랩·rate limit 여유, 1분이 캘린더 한계지만 일봉 신호라 2분 충분) + `StartInterval` 900s→`StartCalendarInterval` 전환(StartInterval은 GUI세션 idle/디스플레이 sleep 시 timer coalescing으로 발화 보류돼 장중 안 돎. calendar는 git-autopush가 새벽 02:00 정상발화로 검증됨) | `python3 -m sim scan` (내부 `scan_all` — 메인+병렬변이 동시) | **자동매매 시뮬(페이퍼)** 1회 스캔. 가상자본 1억(2026-06-10 1천만→1억), 규칙 엔진(LLM 미경유). universe=비하이브 워치+관심+보유(**ETF/ETN은 2026-06-09부터 자동매수 제외** — 애널 방향성 부재로 전략 부정합, `universe.is_etf()` 프리픽스 판별, 보유분은 청산까지 추적). 방향=애널 약한게이트+가점, 타이밍=수급·기술(2026-06-09 시장강약 필터: 하드차단→소프트게이트+장중 실시간 상승비율 + **지수추세 이중확인**). **2026-06-09 4종 추가**: 리스크 기반 사이징(`signals.target_value` 수량=자산×`RISK_PER_TRADE_PCT`÷손절폭, 균등비중 캡)·RSI 필터(돌파 과열/눌림목 과매도 보류)·**시장 국면 6단계**(`market_regime_map`→`engine.REGIME`: 🔥강세·📈반등·🌤완만상승=매수허용 / ➖혼조·📉약세·❄️급락=보류. 상승비율+지수추세+당일등락 조합. market_ok·시장탭 공용. 강세/약세 2단계가 84% 반등을 약세로 오판해 세분화)·**약세장 매수모드**(`BEAR_ENTRY_MODE` 0현금화/1돌파만/2눌림허용, market_ok False일 때 signals 분기. backtest는 시장중립이라 무영향→병렬변이로 라이브 비교. 2026-06-10 **유형36×모드3=108변이 풀매트릭스**로 확장, 첫 라이브 2026-06-11)·시간손절(`MAX_HOLD_DAYS` 미진전 청산)·**추세 질 보강**(2026-06-09 trend_ok 게이트에 ①20일선 우상향 기울기 ②5>60 중기정배열 추가〔종목데이터, backtest 반영〕 + ③상대강도〔종목−지수 N일수익률, 라이브 전용 체크〕). 사이징·시간손절은 엔진·백테스트 공용 헬퍼. TUNABLE 27개. **2026-06-10 코드리뷰 보강**: ①수급DB 일일증분(trade-journal 21시 묶음 3번째 명령 `-m sim.backfill_flow --pages 1 --force` — stale시 백테스트 수급게이트 중립화됨) ②일별 성과이력 `state/sim/equity_history.json`(EOD 덮어쓰기, ph=파라미터해시) ③당일 재진입 쿨다운(`Portfolio.last_exit`, backtest 동일) ④장중 거래량 하루치 환산(`vol_time_frac` — 이전엔 오전 돌파 불가). ⑤백테스트 체결 현실화(당일종가 판단→다음날 시가 체결 pending큐 + `BT_SLIPPAGE` 0.2% — 낙관분 ~5%p 제거) ⑥3분할 꾸준함 순위(`worst_ret` 1순위 — 한 구간 운빨 배제) ⑧**기준전략(M) 폐지**(2026-06-10 — scan_all 메인 패스 execute=False 판단전용·계좌 동결, 비교/매매는 변이 36개만, 메인 제외 규칙 삭제, 마킹 아이콘 6종 `favorites.json {아이콘:vid}`+`/fav_set`〔2026-06-11 ☆토글→아이콘 피커, 아이콘당 전략 1개〕, 관심종목 기본 시각=첫 마킹→1위) ⑦비교군 동기화는 **수동 전용**(2026-06-10 자동 동기화 끔 — sweep은 결과 파일만 갱신, 비교군 변경은 관리자님 명시 요청 시만 `python3 -m sim variants sync`. `sync_label_tops`=유형(~36종)별 1위 동기화, 동일params 계좌 유지. 백테스트 탭은 유형별 대표 읽기전용 요약, 수동 추가/제거·튜닝적용 버튼 제거). 잔여 한계: 검증 데이터가 여전히 ~1년 강세 위주(진짜 하락장 검증은 라이브 누적)·생존편향 universe(의도된 설계). 매수=돌파 즉시/눌림목 지정가, 매도=손절·2:1 후 트레일링·추세수급 이탈. 결과 `state/sim/`(portfolio.json·trades.jsonl·last_scan.json). 실주문 `orders/` 불가침. 일봉은 캐시 우선 읽기(ka10081 rate limit 회피). cwd=`agents/stock/workspace` | | `sim-scan` | 평일 09:00–15:30 **매 2분** (`StartCalendarInterval` 196엔트리, 엔진이 장외/휴장 self-skip) ⚠️2026-06-09 15분→5분, 2026-06-17 5분→2분 단축(스캔 1회 실측 ~3.6초라 2분 cadence 오버랩·rate limit 여유, 1분이 캘린더 한계지만 일봉 신호라 2분 충분) + `StartInterval` 900s→`StartCalendarInterval` 전환(StartInterval은 GUI세션 idle/디스플레이 sleep 시 timer coalescing으로 발화 보류돼 장중 안 돎. calendar는 git-autopush가 새벽 02:00 정상발화로 검증됨) | `python3 -m sim scan` (내부 `scan_all` — 메인+병렬변이 동시) | **자동매매 시뮬(페이퍼)** 1회 스캔. 가상자본 1억(2026-06-10 1천만→1억), 규칙 엔진(LLM 미경유). universe=비하이브 워치+관심+보유(**ETF/ETN은 2026-06-09부터 자동매수 제외** — 애널 방향성 부재로 전략 부정합, `universe.is_etf()` 프리픽스 판별, 보유분은 청산까지 추적). 방향=애널 약한게이트+가점, 타이밍=수급·기술(2026-06-09 시장강약 필터: 하드차단→소프트게이트+장중 실시간 상승비율 + **지수추세 이중확인**). **2026-06-09 4종 추가**: 리스크 기반 사이징(`signals.target_value` 수량=자산×`RISK_PER_TRADE_PCT`÷손절폭, 균등비중 캡)·RSI 필터(돌파 과열/눌림목 과매도 보류)·**시장 국면 6단계**(`market_regime_map`→`engine.REGIME`: 🔥강세·📈반등·🌤완만상승=매수허용 / ➖혼조·📉약세·❄️급락=보류. 상승비율+지수추세+당일등락 조합. market_ok·시장탭 공용. 강세/약세 2단계가 84% 반등을 약세로 오판해 세분화)·**약세장 매수모드**(`BEAR_ENTRY_MODE` 0현금화/1돌파만/2눌림허용, market_ok False일 때 signals 분기. backtest는 시장중립이라 무영향→병렬변이로 라이브 비교. 2026-06-10 **유형36×모드3=108변이 풀매트릭스**로 확장, 첫 라이브 2026-06-11)·시간손절(`MAX_HOLD_DAYS` 미진전 청산)·**추세 질 보강**(2026-06-09 trend_ok 게이트에 ①20일선 우상향 기울기 ②5>60 중기정배열 추가〔종목데이터, backtest 반영〕 + ③상대강도〔종목−지수 N일수익률, 라이브 전용 체크〕). 사이징·시간손절은 엔진·백테스트 공용 헬퍼. TUNABLE 27개. **2026-06-10 코드리뷰 보강**: ①수급DB 일일증분(trade-journal 21시 묶음 3번째 명령 `-m sim.backfill_flow --pages 1 --force` — stale시 백테스트 수급게이트 중립화됨) ②일별 성과이력 `state/sim/equity_history.json`(EOD 덮어쓰기, ph=파라미터해시) ③당일 재진입 쿨다운(`Portfolio.last_exit`, backtest 동일) ④장중 거래량 하루치 환산(`vol_time_frac` — 이전엔 오전 돌파 불가). ⑤백테스트 체결 현실화(당일종가 판단→다음날 시가 체결 pending큐 + `BT_SLIPPAGE` 0.2% — 낙관분 ~5%p 제거) ⑥3분할 꾸준함 순위(`worst_ret` 1순위 — 한 구간 운빨 배제) ⑧**기준전략(M) 폐지**(2026-06-10 — scan_all 메인 패스 execute=False 판단전용·계좌 동결, 비교/매매는 변이 36개만, 메인 제외 규칙 삭제, 마킹 아이콘 6종 `favorites.json {아이콘:vid}`+`/fav_set`〔2026-06-11 ☆토글→아이콘 피커, 아이콘당 전략 1개〕, 관심종목 기본 시각=첫 마킹→1위) ⑦비교군 동기화는 **수동 전용**(2026-06-10 자동 동기화 끔 — sweep은 결과 파일만 갱신, 비교군 변경은 관리자님 명시 요청 시만 `python3 -m sim variants sync`. `sync_label_tops`=유형(~36종)별 1위 동기화, 동일params 계좌 유지. 백테스트 탭은 유형별 대표 읽기전용 요약, 수동 추가/제거·튜닝적용 버튼 제거). 잔여 한계: 검증 데이터가 여전히 ~1년 강세 위주(진짜 하락장 검증은 라이브 누적)·생존편향 universe(의도된 설계). 매수=돌파 즉시/눌림목 지정가, 매도=손절·2:1 후 트레일링·추세수급 이탈. 결과 `state/sim/`(portfolio.json·trades.jsonl·last_scan.json). 실주문 `orders/` 불가침. 일봉은 캐시 우선 읽기(ka10081 rate limit 회피). cwd=`agents/stock/workspace` |
| `sim-web` | 상시 (`KeepAlive`) | `python3 -m sim.sim_web serve` | 시뮬 대시보드 **별도 서버 포트 18792** (behive-web 18790과 분리). `sim.hyowons.net` → mac:18792 (Synology reverse proxy, **관리자님 수동 등록 대기**). Tailnet 한정 무인증. ⚠️ 18791은 node 점유라 18792. **3탭**(2026-06-10~11 개편, 내계좌·튜닝 탭 삭제): **전략**(기본=비교만, 서브탭 버튼 없음 — 백테스트 순위표는 [📊 모의결과보기] 토글)·**관심종목**(전 전략 합산 상태그룹 + 시각 드롭다운 + 👁토스트 맨 위 '▶ 시각' 판단 표시)·**시장**(지수/ADR/투자자). 비교 행 표기: 순위배지 `N위` → 약세장대처 칩(회피/안정/적극 — 2026-06-11 '돌파'→'안정' 개명, 칩 클릭=설명 토스트) → 고유번호 `#N` → 짧은 유형명(`상승장·빠른·공격·추세추종`) → 마킹 아이콘(6종 ⭐🔥💎🎯👀🚀, **아이콘당 전략 1개**, `favorites.json {아이콘:vid}`, `/fav_set`). 펼침 body: 유형(ⓘ=시장적합)→약세장대처(ⓘ)→성적→모의평가(`btN위/36` — 약세모드 3형제는 같은 bt등수 공유). **108변이 = 유형36 × 약세모드3**(2026-06-10 풀매트릭스, v37/38=bt1위의 회피/적극 쌍 순서). 새로고침 탭 보존(라디오↔URL 동기화 + `Cache-Control: no-store`, 2026-06-11). 읽기전용 원칙, 쓰기는 `do_POST` save_params/run_sweep/variants_*/watch_*/fav_set만. 시장 탭은 장중엔 네이버 m.stock 실시간(지수+상승하락·ADR·투자자 순매수, `_get_live_market` 60s 캐시), 장외엔 EOD jsonl 폴백 (2026-06-09: 이전엔 상승하락·투자자가 EOD 고정이라 장중 안 바뀌던 것 수정) | | `sim-web` | 상시 (`KeepAlive`) | `python3 -m sim.sim_web serve` | 시뮬 대시보드 **별도 서버 포트 18792** (behive-web 18790과 분리). `sim.hyowons.net` → mac:18792 (Synology reverse proxy, **관리자님 수동 등록 대기**). Tailnet 한정 무인증. ⚠️ 18791은 node 점유라 18792. **3탭**(2026-06-10~11 개편, 내계좌·튜닝 탭 삭제): **전략**(기본=비교만, 서브탭 버튼 없음 — 백테스트 순위표는 [📊 모의결과보기] 토글)·**관심종목**(전 전략 합산 상태그룹 + 시각 드롭다운 + 👁토스트 맨 위 '▶ 시각' 판단 표시)·**시장**(지수/ADR/투자자). 비교 행 표기: 순위배지 `N위` → 약세장대처 칩(회피/안정/적극 — 2026-06-11 '돌파'→'안정' 개명, 칩 클릭=설명 토스트) → 고유번호 `#N` → 짧은 유형명(`상승장·빠른·공격·추세추종`) → 마킹 아이콘(6종 ⭐🔥💎🎯👀🚀, **아이콘당 전략 1개**, `favorites.json {아이콘:vid}`, `/fav_set`). 펼침 body: 유형(ⓘ=시장적합)→약세장대처(ⓘ)→성적→모의평가(`btN위/36` — 약세모드 3형제는 같은 bt등수 공유). **108변이 = 유형36 × 약세모드3**(2026-06-10 풀매트릭스, v37/38=bt1위의 회피/적극 쌍 순서). 새로고침 탭 보존(라디오↔URL 동기화 + `Cache-Control: no-store`, 2026-06-11). 읽기전용 원칙, 쓰기는 `do_POST` save_params/run_sweep/variants_*/watch_*/fav_set만. 시장 탭은 장중엔 네이버 m.stock 실시간(지수+상승하락·ADR·투자자 순매수, `_get_live_market` 60s 캐시), 장외엔 EOD jsonl 폴백 (2026-06-09: 이전엔 상승하락·투자자가 EOD 고정이라 장중 안 바뀌던 것 수정) |
+455 -11
View File
@@ -46,6 +46,7 @@ CHART_HLINES = WORKSPACE / 'state' / 'behive_chart_hlines.json'
INTEREST_GROUPS = WORKSPACE / 'state' / 'behive_interest_groups.json' INTEREST_GROUPS = WORKSPACE / 'state' / 'behive_interest_groups.json'
HOLDEVAL_SAVED = WORKSPACE / 'state' / 'behive_holdeval_saved.json' HOLDEVAL_SAVED = WORKSPACE / 'state' / 'behive_holdeval_saved.json'
SURGE_TOGGLES = WORKSPACE / 'state' / 'behive_surge_toggles.json' SURGE_TOGGLES = WORKSPACE / 'state' / 'behive_surge_toggles.json'
IPO_SUBSCRIBED = WORKSPACE / 'state' / 'behive_ipo_subscribed.json'
ALERTS_STATE = WORKSPACE / 'state' / 'watchlist_alerts.json' ALERTS_STATE = WORKSPACE / 'state' / 'watchlist_alerts.json'
HOLIDAYS_FILE = WORKSPACE / 'state' / 'market_holidays.json' HOLIDAYS_FILE = WORKSPACE / 'state' / 'market_holidays.json'
SNAPSHOT_FILE = WORKSPACE / 'state' / 'portfolio_daily_snapshot.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 로 폴백. # NXT 미거래 종목은 AL == KRX 라 결과가 같다. AL 이 불완전할 때만 KRX 로 폴백.
qk_full = qk_valid and qk.get('open') and qk.get('high') and qk.get('low') 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') 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: if primary:
r['ohlc'] = {'o': primary['open'], 'h': primary['high'], 'l': primary['low'], 'c': primary.get('price', 0) or r.get('price', 0)} 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) 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(): def _holdeval_lock():
"""HOLDEVAL_SAVED 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일.""" """HOLDEVAL_SAVED 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
import fcntl as _fcntl 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) 부착. extra = '' # 현재가 셀에만 WS 라이브 갱신 마커(data-rt-ohlc-cur) + 전일종가(data-prev) 부착.
if key == 'price': if key == 'price':
# 현재가는 AL(통합) 가격을 양 컬럼 공통으로 표시(동일가). 등락률만 각 시장 전일종가 대비로 분리. # 현재가는 AL(통합) 가격을 양 컬럼 공통으로 표시(동일가). 등락률만 각 시장 전일종가 대비로 분리.
price = al_price or d.get('price', 0) # ⚠️ 정규장(active_market='krx')의 KRX 열은 KRX 자체 현재가 — 실시간 틱은 통합(_AL)뿐이라
base = (d.get('prev_close', 0) or 0) if al_price else 0 # 라이브 마커(data-rt-ohlc-cur)도 달지 않는다(달면 통합 틱이 KRX 값을 덮어쓴다).
if al_price and base: if d is krx and active_market == 'krx':
pct = (price - base) / base * 100 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: else:
pct = d.get('change_pct', 0.0) or 0.0 price = al_price or d.get('price', 0)
extra = f' data-rt-ohlc-cur data-prev="{int(base) if base else 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': elif key == 'close_today':
# 당일종가 — 그 시장이 마감된 뒤에만 그 시장의 종가(=ka10095 price)를 표시. 장중엔 미확정(·). # 당일종가 — 그 시장이 마감된 뒤에만 그 시장의 종가(=ka10095 price)를 표시. 장중엔 미확정(·).
if not closed: if not closed:
@@ -4500,7 +4626,8 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
# 활성 열 판정 — OHLC 미니테이블 강조용. 두 열은 'KRX 단독' vs '하루 전체 통합'이라 # 활성 열 판정 — OHLC 미니테이블 강조용. 두 열은 'KRX 단독' vs '하루 전체 통합'이라
# 장중이면 항상 통합 열이 최신(현재가와 같은 값)이고, KRX 열은 15:30 이후 마감값으로 고정된다. # 장중이면 항상 통합 열이 최신(현재가와 같은 값)이고, KRX 열은 15:30 이후 마감값으로 고정된다.
_phase = _market_phase_state()['phase'] _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 빼고 전부 / 장외·휴장·주말: 없음 # 정규장: 전부 / NXT 시간대: nxt_enable 만 / 애프터마켓: ETF/ETN 빼고 전부 / 장외·휴장·주말: 없음
_tradable = _phase_tradable(_phase, r.get('code') or '') _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)); padding: 10px 16px; padding-top: max(10px, env(safe-area-inset-top));
display: flex; justify-content: space-between; align-items: center; gap: 10px; 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 .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 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; } 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; font-size: 9px; font-weight: 600; letter-spacing: 0.04em;
color: #fbbf24; 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-tab — 자산보기/차트보기. 상단 배치는 밑줄 강조, 하단 배치는 일반 버튼 형태. */
.sub-tabs { display: flex; gap: 4px; margin: 8px 14px 0; border-bottom: 1px solid #1f2330; } .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; } .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; border-radius: 8px;
background: #11141c; background: #11141c;
color: #c9ccd3; 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:hover, .sub-tabs.sub-tabs-bottom .sub-tab:active { background: #1a1d26; color: #f0f0f0; }
.sub-tabs.sub-tabs-bottom .sub-tab.active { .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: 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 카드 스택 (합산 / 각계좌별도 토글) - 자산보기(기본): owner별 compact KPI 카드 스택 (합산 / 각계좌별도 토글)
- 차트보기: 기간·단위·모드 select + owner별 차트 (순자산 / 손익누적 토글) - 차트보기: 기간·단위·모드 select + owner별 차트 (순자산 / 손익누적 토글)
- 시장정보: 오늘의 ADR·투자자별 매매 카드 + ADR 추세 sparkline (기간 select, 라이브 포인트) - 시장정보: 오늘의 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() market_card = _render_market_indicators_card()
adr_trend_card = _render_adr_trend_card(days=adr_days) 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곳으로 통일. # 자산보기 sub-panel 안 합산/각계좌별도 panes (양쪽 prerender). 토글 버튼은 페이지 상단(자동 토글 옆) 1곳으로 통일.
assets_inner = ( assets_inner = (
'<div class="account-view-pane" data-account-view-pane="consolidated">' '<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 + market_card
+ adr_trend_card + adr_trend_card
+ '</div>' + '</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">' '<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 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="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="market" role="tab" aria-selected="false">시장정보</button>'
'<button type="button" class="sub-tab" data-sub-tab="ipo" role="tab" aria-selected="false">공모주</button>'
'</div>' '</div>'
) )
@@ -9097,6 +9464,7 @@ def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_
'tabs': tabs, 'tabs': tabs,
'indices': indices, 'indices': indices,
'ticker_items': indices, 'ticker_items': indices,
'ipo_flag': _ipo_flag(),
} }
@@ -9264,6 +9632,10 @@ def render_html() -> str:
'}' '}'
'setMarket(!!p.market_active);' 'setMarket(!!p.market_active);'
'setTicker(p.ticker_items||p.indices||[]);' '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){' 'var byId={};(p.tabs||[]).forEach(function(t){'
'byId[t.id]=t;' 'byId[t.id]=t;'
# partial 응답이라도 자기 탭만 fetched_at 갱신 — 다른 탭 값은 그대로. # partial 응답이라도 자기 탭만 fetched_at 갱신 — 다른 탭 값은 그대로.
@@ -9281,7 +9653,13 @@ def render_html() -> str:
'sec.querySelectorAll("details.row[open][data-row-key]").forEach(function(d){' 'sec.querySelectorAll("details.row[open][data-row-key]").forEach(function(d){'
'openKeys[d.getAttribute("data-row-key")]=1;' '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;' '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){' 'Object.keys(openKeys).forEach(function(k){'
# 그룹 탭에서 같은 종목이 전체·그룹 패널에 중복 존재 → 매칭되는 모든 행 복원. # 그룹 탭에서 같은 종목이 전체·그룹 패널에 중복 존재 → 매칭되는 모든 행 복원.
'sec.querySelectorAll(\'details.row[data-row-key="\'+k.replace(/"/g,\'\\\\"\')+\'"]\').forEach(function(d){d.setAttribute("open","");});' '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");' 'openModal("note-modal");'
'return;' '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. 실패하면 되돌린다. # 급등락 알림 토글 — 낙관적 갱신(즉시 반영) 후 POST. 실패하면 되돌린다.
# ⚠️ 패널 전체 새로고침(__behive_load)을 부르지 않는다 — 키움 조회까지 다시 돌아 # ⚠️ 패널 전체 새로고침(__behive_load)을 부르지 않는다 — 키움 조회까지 다시 돌아
# 버튼이 몇 초 뒤에야 바뀐다. 토글 말고 달라지는 게 없어서 DOM만 고치면 충분하고, # 버튼이 몇 초 뒤에야 바뀐다. 토글 말고 달라지는 게 없어서 DOM만 고치면 충분하고,
@@ -14355,7 +14779,7 @@ window.openPinModal = openPinModal;
<div class="topbar"> <div class="topbar">
<header class="top"> <header class="top">
<div class="titles"> <div class="titles">
<h1>자산현황</h1> <h1>자산현황<span id="ipo-flag" class="ipo-flag" hidden></span></h1>
<span class="meta">갱신 {now}</span> <span class="meta">갱신 {now}</span>
</div> </div>
<div class="top-actions"> <div class="top-actions">
@@ -16288,6 +16712,26 @@ class Handler(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
return 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': if self.path == '/surge/toggle':
# 급등락 알림 on/off. code 필수 (모니터가 코드로 시세를 조회하므로 name fallback 없음). # 급등락 알림 on/off. code 필수 (모니터가 코드로 시세를 조회하므로 name fallback 없음).
# on 파라미터 없으면 현재 상태를 뒤집는다. # on 파라미터 없으면 현재 상태를 뒤집는다.
+232
View File
@@ -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()