diff --git a/CLAUDE.md b/CLAUDE.md index 1f304ec1..e14baa53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,7 +110,7 @@ Stock agent (`agents/stock/workspace/scripts/`), run with `python3`: - `surge_monitor.py` — **급등락 알림** (2026-08-04). 토글 켜진 종목만 감시(`state/behive_surge_toggles.json`, behive_web 🔔 버튼이 씀). **세 기준 병행**: ①거래소 **상·하한가 도달**(2026-08-06 추가) = 그날 갈 수 있는 끝까지 간 것이라 가장 강한 신호, 무엇에도 가리지 않는다 ②거래소 **VI 발동** = "이건 급등락이다"를 거래소가 공식 판정하므로 문턱을 우리가 안 정해도 된다 ③**자기 이력 분위수** = 장중 이탈폭(전일종가 대비) ≥ 그 종목 과거 이탈폭의 `SURGE_PCTL`(p90) 분위수, 확대단계는 `SURGE_PCTL_BIG`(p98). ⚠️ **고정 %는 구조적으로 불가** — 실측(관심·감시 65종목 × 280거래일) 장중 이탈폭 중간값이 3.8%라 ±3%는 평범한 날이고, 종목별 변동성이 22배 차(ATR14 1.0~22.3%)라 ±5% 문턱이면 KODEX 미국S&P500은 발생 0회·SK이터닉스는 75회다. ⚠️ **ATR 배수(ATR%×K)도 쓰면 안 된다 — 2026-08-04 관리자님 지적으로 폐기**: 국내 하루 가격제한폭이 **±30%**인데 ATR 배수는 상한이 없어 변동성 큰 종목의 문턱이 제한폭 밖으로 밀려난다. 실측 1차(ATR×1.5) 5/65종목·확대(ATR×3.0) **31/65(48%)**가 30% 초과 = **영원히 발동 불가**였다(ATR% 중간값 10%라 확대는 절반이 죽음). 분위수는 실제 관측된 이탈폭이라 구조적으로 제한폭을 넘을 수 없고(p90~p99 전부 30% 초과 0종목), 알림량도 정의상 (1−p)로 확정된다 — p90 = 종목당 연 25회. **다시 ATR 배수로 되돌리지 말 것.** 데이터원은 behive_web `/api/realtime/quotes` 1콜 — 현재가와 VI를 한 번에 주고 **키움 콜 0**. VI(1h)는 시장 전역 broadcast라 구독 불필요, 현재가(0B)는 구독 필요한데 `_rt_gather_codes()`가 이미 보유+관심+감시를 구독하므로 토글 종목은 부분집합(상한에 걸려 빠진 종목만 ka10095 배치 1콜 폴백). ⚠️ **문턱은 하루 1회만 계산**(`state/surge_thresholds.json`, `pctl` 서명 불일치 시 재계산) — `daily_candles_cache.get_candles`는 캐시 최신봉이 어제보다 오래되면 ka10081을 때리므로 매분 × 종목수로 부르면 폭주. ⚠️ **ka10081 유량이 초당 5건**이라 한꺼번에 돌리면 429가 쏟아진다(실측 70종목 일괄 산출 시 26종목 실패) → 사이클당 `MAX_COMPUTE_PER_CYCLE`(12)개까지 `COMPUTE_PACE_SEC`(0.35s) 간격으로만 계산, 나머지는 다음 사이클이 이어받는다(70종목 = 6사이클 ≈ 6분, 사이클당 4~5초 실측). ⚠️ **조회 실패는 캐시하지 않는다** — 이력부족(`{}`)과 같이 취급하면 429 한 번에 그 종목이 하루 내내 조용히 VI만 감시하게 된다. `_compute_thresholds`가 `(결과, 재시도여부)` 튜플을 반환하는 이유. ⚠️ **판정은 `prev_close`와 무관**하다(`pct` % vs 문턱 % 비교) — 캐시가 하루 stale이면 `list`의 발동가 표시만 어긋나고 트리거는 정확하다. ⚠️ **상·하한가·VI 가 걸린 동안 분위수 트리거는 생략이 아니라 보류**다(2026-08-06 관리자님 요청으로 변경). 같은 사건을 두 번 알리지 않으면서도, 그 사이 문턱을 넘은 사실은 `surge_alerts.json`의 `__pending__`(종목→키→`{pct,word,reason,via}`, pct는 이탈폭 최대 시점)에 적어뒀다가 **가림이 풀린 사이클에 방출**한다. 그전엔 조용히 버려져서, **VI 중 문턱 돌파 후 해제 시점에 되밀린 움직임은 알림이 아예 없었다**. ⚠️ **가림 여부를 "보류할 트리거가 비었는지"로 판단하면 안 된다** — VI 중 주가가 문턱 아래로 되밀리면 그것도 비어서, VI 도중에 보류분이 새어나간다. `evaluate`가 세 번째 반환값으로 **가림사유 문자열**을 따로 주는 이유. ⚠️ **방출분은 발송 성공 시에만 보류에서 지운다**(텔레그램 실패 시 재시도 대상). ⚠️ **알림 아이콘은 pct 가 아니라 방향어에서 파생**한다 — 방출 건은 돌파 시점 방향(급락)과 방출 시점 현재가 부호(+)가 어긋날 수 있어 pct 로 고르면 `🚀 급락`이 찍힌다. ⚠️ **VI 가 장 마감까지 안 풀리면 그날 보류분은 방출되지 않는다**(강제 flush 없음). ⚠️ **상·하한가는 거래소 계산값(ka10095 `upl_pric`/`lst_pric`)을 그대로 쓴다** — 제한폭은 기준가의 호가단위로 절사한 뒤 ±하는 규칙이라 직접 계산하면 ETF·가격대별 호가단위 표를 재현해야 해 틀릴 여지가 있다(VI와 같은 이유). 기준가로 정해져 장중 불변이라 `state/surge_limits.json`에 **하루 1콜**만 캐시하고, 일봉 이력이 필요 없어 **이력 부족 종목도 이 기준으론 감시된다**. ⚠️ **VI 레그는 behive_web 프로세스에 의존** — 죽으면 VI 알림이 조용히 사라지고 stderr 경고만 남는다(자동 재시도·백업 트리거는 의도적으로 없음). 이력 `MIN_HISTORY`(60일) 미달 종목은 VI·상하한가만 감시(신규상장·정리매매는 제한폭 예외지만 애초에 이력이 없어 여기 해당). dedup은 `state/surge_alerts.json`에 날짜·종목별 트리거키 1회씩 — 분위수 방향별 1회 + 확대단계 1회 + `limit_up`/`limit_down` 1회, VI 는 `vi:<발동시각>`이라 **발동 건마다** 1회(하루 상한 없음). 감시 대상은 **코드 기준**(메모와 달리 종목명 fallback 없음 — 시세 조회에 코드가 필요). CLI: `check [--force]` / `dry-run`(보류 현황 표시) / `list`(문턱%·급등가·급락가·상하한가 표시) - `trailing_monitor.py` — **트레일링 스톱 감시** (2026-07-30). 키움 REST에 트레일링 주문 TR이 없어서, 스톱지정가(`trde_tp=28`) 주문을 실제로 걸어두고 고점 갱신 시 `kt10002` 정정(`mdfy_cond_uv`)으로 조건단가를 올려 구현. **주문이 키움 서버에 있어 감시 루프가 죽어도 마지막 손절선은 살아있다** (자체 폴링 발주 방식은 루프가 죽으면 손절이 통째로 사라짐 — 이 차이가 설계 선택 이유). **예약 1건 = 계단(레그) N개**라 생존 확인·정정 모두 레그 단위로 돈다. 사이클당 하는 일: ①`ka10075` 미체결 조회로 **레그별** 생존 확인(계좌당 1콜). 사라진 레그만 정리하고 나머지 계단은 감시 유지 + **사유 구분 알림** — 미체결 조회만으로는 체결·취소·소멸이 구분되지 않아(셋 다 목록에서 빠짐) 그때만 `kt00007` 1콜 추가로 판정: ✅체결(그 `ord_no`의 `cntr_qty`>0) / ⚠️소멸(체결수량 0 = 체결 안 됨 확정, 재등록 안내) / 🎯판정불가(조회 실패). ⚠️ **판정은 반드시 주문번호 단위 `kt00007`로** — 종목 단위 집계인 `ka10170`을 쓰면 **한 계단이 체결된 날 나머지 계단이 장 마감으로 소멸했을 때 그 소멸분까지 '체결'로 오판**한다(같은 종목 매도기록이 이미 남아 있어서). 2026-07-31 계단식 전환 때 교체. ⚠️ **조회 실패를 '체결 안 됨'으로 단정 금지** — `_leg_fill_check` 가 (기록, 성공여부) 튜플을 반환하는 이유. 알림은 **예약 단위로 묶어 1건**(장 마감이면 계단이 통째로 사라져 레그마다 보내면 3~5통이 몰아친다) ②`ka10095` 시세 1콜 → 고점 갱신 시 살아있는 레그 **전부** 정정(정정 콜이 계단 수에 비례). 주가가 그대로·하락이면 API 콜 0. ⚠️ **`kt10002` 응답 `ord_no`는 신규 주문번호** — `trailing.commit_step_modify`가 상태파일의 **레그별** `ord_no`를 갱신하지 않으면 그 레그의 두 번째 정정부터 `orig_ord_no`가 틀려 전부 실패(최대 함정). ⚠️ **한 계단이 체결돼도 남은 계단을 재배치하지 않는다**(2026-07-31 관리자님 결정) — 남은 수량을 계단 비율대로 다시 쪼개려면 기존 주문 취소+신규 발주가 필요해 감시 루프에 자동 발주 경로가 생긴다. 남은 계단은 새 고점 기준으로 따라 올라가기만 한다. ⚠️ **고점은 등록 시점 현재가에서 시작해 현재가로만 갱신**한다 — `ka10095`가 당일고가(`high`)도 주지만 등록 전에 찍힌 고가까지 반영되면 손절선이 현재가 위로 올라가 즉시 발동(예: 오전 15,000→14,000일 때 3% 등록 시 당일고가 기준 조건 14,550 > 현재가). 같은 이유로 초기 고점 선택 옵션(52주 전고점·매수후 고점)도 제거됨. 1분 간격이라 그 사이 스파이크는 놓친다(손절선이 덜 올라가 이익 확정 폭이 조금 줄어드는 방향 — 손실 위험은 아님). ⚠️ **정밀도 보완은 하지 않기로 결론**(2026-07-30 관리자님 판단): 최저 매도가로 하한이 통제되므로 1분 해상도의 고점 누락은 감당 범위. 검토했던 두 안 — `entry_day_high`(등록 시점 당일고가를 기준선으로 저장해 그보다 큰 `high`만 등록 이후 신고가로 인정. 추가 콜 0이지만 기준선 아래 스파이크는 여전히 누락) / `realtime_hub` WS 틱 고점 추적(정확하지만 구독·재연결 폴백 필요) — **둘 다 채택 안 함. 다시 제안하지 말 것.** ⚠️ **`fill_watcher`를 걸지 않는다** — 스톱 예약은 장중 내내 미체결이 정상이라 30분 미체결 알림이 오탐. 생존·체결 감시는 이 스크립트가 직접. ⚠️ **스톱주문은 장 마감 후 소멸한다(2026-07-30 실증)** — 첫 실주문이 체결 없이 사라짐(당일 매도기록 0·보유 불변), 감지 18:21로 15:30 즉시가 아니라 몇 시간 뒤. **자동 재등록은 하지 않는다**(관리자님 결정) — 알림만 보내고 재등록은 자산웹에서 수동. **재등록 버튼**(2026-08-27): 자산웹 `[📋 진행중]` 모달 하단 **최근 종료** 섹션의 `↺ 재등록` 이 **거래 모달을 어제 설정(계단·최저 매도가·수량·발동가)으로 채워서 열어줄 뿐**이고 카드·확인 팝업·PIN 은 평소와 동일 — **감시 루프가 스스로 되살리는 경로는 여전히 없다.** 목록 대상은 **장 마감 소멸 + arm 만료 2종뿐**(체결·판정불가·수동취소·발동불가·`firing_at` 중단분 제외). ⚠️ **고점은 승계 안 함**(어제 고점이면 `TRAIL_IMMEDIATE` 거부). ⚠️ **사이클 걸친 부분체결**은 그 배치에 체결이 없어 조건을 통과하므로 `len(evs) == len(entry_steps)` 로 막는다. ⚠️ 예약의 **`entry_steps`**(등록 시점 계단 정의)가 필요한 이유 — `remove_step` 스냅샷은 레그를 뺀 **뒤**라 마지막 레그가 빠지면 `steps: []` 다. ⚠️ 소멸 알림 문구도 **기록 조건과 같이 분기**한다(체결·판정불가면 재등록 버튼을 언급하지 않는다 — 없는 목록으로 안내 금지). ⚠️ 최근 종료는 배지 카운트(`unique_count`)에 **넣지 않되** `/api/order/active` 의 `recent_count` 로 **팝업은 열리게** 한다(미체결 0건이면 토스트로 끝나 목록에 닿을 길이 없다). 저장은 `trailing_stops.json` 세 번째 키 **`recent`**(20건·7일 링버퍼, UI 전용이지 감사 원장 아님 — `_read_doc`/`_write` 를 반드시 같이 고칠 것). 정정 수량은 저장 qty가 아닌 **미체결 잔량**(부분체결 대응). 미체결 조회 실패 계좌는 그 사이클 판단 보류(없다고 단정하면 살아있는 예약을 지움). CLI: `check [--repeat N --gap S] [--force] [--dry-run]` / `list` - `orders/trailing.py` — 트레일링 예약 상태(`state/trailing_stops.json`) + 손절선 계산. **예약 1건 = 계단(레그) N개 = 키움 스톱주문 N건**(계단식 분할 매도, 2026-07-31). 하락률이 깊어질수록 더 많이 판다 — 예 −10% 20% / −20% 50% / −30% 전량. `compute_levels(peak, pct, min_sell_price=None)` = `cond_uv` = **max(peak×(1−pct/100), min_sell_price)** 호가단위 내림 + `ord_uv`(조건단가 −`ORD_UV_GAP_TICKS` 2틱 — 조건단가와 같게 두면 발동 후 그 가격 아래로 안 팔려 미체결로 남고, 너무 벌리면 급락 시 헐값 매도). ⚠️ `int(round(peak×(1−pct/100), 6))` 의 **round 는 부동소수점 잡음 제거용**(5500×0.7이 3849.9999999999995라 int()가 3849로 깎고 호가내림이 3845까지 끌어내리던 버그, 2026-07-31 수정). 계단 함수 4종: `normalize_steps(raw)`=입력한 **누적** 비중(20/50/100)을 **추가** 비중(20/30/50)으로 환산+검증(하락률·누적 둘 다 순증가, 하락률 0.5~30%, 누적 ≤100%〔100 미만 허용=일부만 계단 청산〕, 계단 ≤`MAX_STEPS` 5) / `allocate_step_qty(total, weights)`=**최대잔여법**(내림 후 소수부 큰 계단부터 1주씩 — "내림 후 마지막에 몰아주기"보다 얕은 계단이 0주로 죽는 일이 적다: 2주·20/30/50 → `[0,1,1]` vs `[0,0,2]`) / `compute_step_levels(peak, steps, floor)` / `next_step_levels(res, cur)`. **최저 매도가**는 손절선의 하한 — 트레일 폭을 넓게 잡아도 이 가격 아래로 안 내려간다(초기 구간 손실 제한). ⚠️ **floor 는 모든 계단에 같은 하한으로 걸려** 초기엔 여러 계단이 같은 가격으로 뭉칠 수 있다. **그래도 주문을 병합하지 않는다** — 고점이 올라 트레일이 floor를 추월하면 각 계단이 제 하락률대로 다시 벌어지는데, 병합하면 정정만으로는 못 쪼갠다(신규 발주가 필요해짐). 전환점 = `min_sell_price ÷ (1−pct/100)`, 예: 최저 4,500·20% → 5,625원. `next_step_levels(res, cur)`는 **상향 전용 순수함수** — 고점 안 올랐으면 None, 올랐으면 `{peak, steps:[올릴 레그만]}`. ⚠️ **steps 가 빈 리스트일 수 있다**(호가내림·floor 지배로 올릴 조건단가가 없는 경우) → 호출측은 정정 API 없이 `commit_peak`로 고점만 갱신(콜 0, 화면 고점 표시는 정확 유지). 상태 갱신은 레그 단위: `register_steps`/`commit_step_modify`/`remove_step`(마지막 레그 제거 시 예약째 삭제)/`commit_peak`. 같은 계좌·종목 중복 예약은 `find_by_symbol`로 propose 단계에서 차단. fcntl lock + atomic write (`pin.py` 패턴) -- `behive_web.py` — 워치리스트 실시간 웹 뷰 + 매매 진입점. `serve`(launchd, Tailscale IP 100.75.148.12:18790 바인드, 페이지 GET마다 키움 ka10095 batch 1콜로 워치리스트 시세 + kt00018·kt00001·ka10170 병렬 호출 후 HTML 응답. RENDER 캐시 10s, 종목별 quote 캐시 30s) / `render`(디버깅용 1회 렌더). 외부 노출은 NAS Synology reverse proxy(`stock.hyowons.net` → mac:18790) 경유. 인증 없음 — Tailnet 내부망 한정 운영. **빌드 버전 자동 리로드**(2026-07-03): 이 앱은 `location.reload` 없이 fetch로만 갱신해 코드 수정+재시작 후에도 열려있던 페이지는 옛 인라인 JS/CSS를 계속 씀. `BUILD`(파일 mtime, import 시 1회) 상수를 shell `window.__build`와 `/api/panels` 응답 `build`에 실어, `apply()`가 불일치(옛 페이지=`__build` 없음 포함) 감지 시 `sessionStorage` 가드로 build당 1회 자동 리로드 → 최신 JS/CSS 반영(리로드로도 안 맞으면 루프 없이 포기). 보유종목 day_change 보정은 brifing 과 동일 A-4 정책. **ka10095 를 KRX+AL 2종으로 통일**(2026-09-10): ⚠️ 예전엔 보유(KRX/NX/AL 3콜)와 감시·관심(AL + NX probe)이 제각각이었고 NXT 단독 조회가 곳곳에 있었다. 지금은 **NXT 단독 조회가 없다** — ①현재가 보정은 AL 하나로(AL 이 곧 '지금 활성 시장의 최신 체결'이라 예전의 phase별 분기 regular=KRX/nxt=NXT 가 통째로 사라짐) ②RVOL 의 KRX 거래량은 KRX 를 직접 조회(예전 `AL−NXT` 역산은 두 조회의 시점차만큼 오차가 났다 — 실측 삼성전자 14주) ③NXT 거래 여부는 `_is_nxt_traded()` = `state/stock_codes.json` 의 `nxt_enable`(**키움 콜 0**, 코드 키 역인덱스 5분 캐시 `_nxt_enable_map`). ⚠️ `kc.lookup_stock_meta` 를 행마다 부르면 안 된다 — 호출마다 954KB JSON 을 다시 읽고 3500종목을 순회해 100종목이면 렌더가 멈춘다. ⚠️ KRX 보정 조회는 **`nxt_enable=True` 종목만** 한다 — NXT 미거래 종목은 AL 이 곧 KRX 값이라 조회해도 같은 값이 온다(101종목 전수 실측 예외 0건). 종목 수가 늘어도 성립하는 규칙이라 '몇 종목이니 몇 콜'식 가정에 기대지 않는다. **실측 렌더당 ka10095 10콜 → 7콜**(감시·관심 101종목이 40개 배치 한도로 3콜씩 쪼개지는 게 대부분이다 — 콜 수를 셀 때 이 분할을 빠뜨리기 쉽다). ⚠️ 초록점 판정이 meta 기반이 되면서 **계좌별 행에도 초록점이 붙는다**(예전엔 `ohlc_nxt` 가 consolidated 행에만 주입돼 계좌별 행에서 조용히 빠졌다). **듀얼 OHLC 미니표는 `KRX | 통합`**(2026-09-10, 관리자님 결정): 예전 `KRX | NXT` 에서 두 번째 열을 통합으로 교체. NXT 시간대엔 통합 = NXT 최신 체결이라 가격 4값이 같고 거래량만 합산이며, 정규장엔 통합이 프리마켓을 포함해 KRX 와 갈린다(실측 삼성전자 시가 269,000 vs 268,000). ⚠️ 통합 열 시가는 **덮어쓰지 않는다** — 예전 NXT 열은 시가 칸에 전일종가를 넣었지만 통합 시가는 NXT 프리마켓 첫 체결이라 그 자체가 정보다. ⚠️ 통합 열 전일종가는 **`r['pred_close']` 를 쓰면 안 된다** — 그 값은 `rebase_to_nxt_close` 가 만드는 '당일 등락 baseline' 이라 **어제 보유가 없던 종목(오늘 신규 매수)엔 매수 평단이 들어간다**(실측 2026-09-10 우리기술: 평단 13,780 이 전일종가 자리에 노출, 실제 전일종가는 14,320). 그래서 rebase **직전에** 어제 스냅샷 종가를 `pred_close_snap` 으로 따로 보존하고, 미니표는 ①`pred_close_snap`(어제 보유분 = 어제 NXT 종가) ②없으면 ka10095 AL 의 `현재가 − 전일대비` 순으로 쓴다. ⚠️ 기존 NXT 열에도 같은 코드였지만 'NXT 미거래 + 오늘 신규매수' 두 조건이 겹쳐야 보이는 자리(NXT 미거래면 열이 통째로 비었다)라 드러나지 않았다. ⚠️ 보유행 '당일 등락'은 여전히 평단 기준이다 — 오늘 산 종목을 어제 종가와 비교하는 건 손익 관점에서 무의미하므로 의도된 동작이고, 미니표(시장 데이터)와 목적이 다르다. ⚠️ 미니표 전환은 **콜 절감이 목적이 아니다**(보유 NX 콜은 어차피 감시·관심 NXT 조회에 얹으면 공짜였다) — 화면 일관성 때문에 선택한 것. **감시·관심 카드 미니표도 같은 라벨**(2026-09-11): `_render_ohlc_mini` 는 구조가 다르다 — 가격은 통합(AL) 하나이고 **등락률만 두 기준**으로 나뉜다(①KRX 전일종가 ②어제 통합 마지막 체결 = `_watchlist_nxt` 스냅샷의 NXT 종가). 보유행 `통합` 열과 같은 개념이라 값·데이터원은 그대로 두고 헤더 라벨만 'NXT'→'통합' 으로 맞췄다. ⚠️ 보유행과 달리 **폴백(없으면 KRX 전일종가)을 두지 않는다** — 두 컬럼이 같은 값이 되어 중복이다(NXT 미운영 종목은 어제 종가가 곧 KRX 종가라 원래 두 값이 같다). ⚠️ 320px 에서 미니표가 6px 넘치는 건 라벨과 무관한 기존 동작이다(라벨을 'NXT' 로 되돌려도 동일, 컬럼 폭 56px 불변 — 360px 이상은 넘침 0). **시장 phase 4종**(2026-09-21 `after` 신설): `regular`(09:00~15:30) / `nxt`(08:00~09:00 프리 + 15:30~16:00) / **`after`(16:00~20:00 = KRX 애프터마켓 + NXT 애프터마켓)** / `closed`·`holiday`·`weekend`. ⚠️ **KRX 애프터마켓 시행(2026-09-14)으로 NXT 미상장 종목도 16:00 이후 거래된다** — 한 값으로 뭉쳐 두면 `nxt_enable=False` 인 2,600여 종목이 부당하게 잠긴다(실측 보유 16·관심 26·감시 46종목이 '값은 갱신되는데 버튼은 잠긴' 상태였다). ⚠️ **`after` 추가 시 `== 'nxt'` 비교 소비처를 전부 같이 고쳐야 한다** — 안 고치면 else 로 떨어져 실시간 가격 보정(`active_map`)·거래가능 초록점 2곳·OHLC 활성열이 **조용히 죽는다**. 그래서 거래시간 판정은 `_PHASE_TRADING` 상수로, 종목별 거래가능은 **`_phase_tradable(phase, code)` 한 함수로 모았다**(예전엔 미리보기 행과 보유행에 같은 if/elif 가 복제돼 있어 한쪽만 고치면 같은 종목이 화면 두 곳에서 다르게 보였다). ⚠️ **애프터마켓 차단 대상은 ETF/ETN 뿐**이고 판별은 `stock_codes.json` 의 `etf_etn` 플래그다(2026-09-21 실측: TIGER 200 은 15:35 이후 거래량·현재가·호가잔량이 **완전 고정**). 제외종목 9종(거래정지·투자경고·이상급등 등)은 판별 필드가 없어 완전 차단이 불가능하다 — 막을 수 있는 것만 막고 나머지는 키움 거부 메시지로 드러낸다. ⚠️ 15:30~16:00 은 `nxt` 로 남긴다(KRX 쪽은 시간외 종가매매뿐이고 우리는 `trde_tp=81` 을 쓰지 않는다). ⚠️ 호가·현재가는 애프터마켓엔 **KRX(bare)** 를 쓴다 — KRX 애프터마켓 호가가 bare 조회로 그대로 온다(실측 삼성전자우 매도1/매수1 이 16:05·17:00·18:00 모두 갱신). ⚠️ **KRX OHLC 열의 '종가' 표시는 15:30~16:00 과 20:00 이후만**이다 — 애프터마켓엔 KRX 가 접속매매로 다시 돌아 가격이 변하므로, 예전처럼 15:30 이후 전부 종가로 두면 '종가'라 써놓고 값이 계속 바뀐다. **애프터마켓 종가 ≠ 공식 전일종가**(2026-09-22 실측): 오늘의 전일종가는 **어제 15:30 정규장 종가**다(7종목 전수 — 삼성전자우 정규장 208,000 / 애프터 마지막 210,500 → 오늘 전일종가 208,000). 그런데 `stock_portfolio_report` 는 스냅샷을 **20:10** 에 찍고 kt00018 을 **`exchange='NXT'`** 로 조회해서, NXT **상장** 종목엔 애프터 종가가 NXT **미거래** 종목엔 **stale**(정규장 종가)이 들어간다 — **한 스냅샷에 두 기준이 섞인다**(어제 실측: 삼성전자 275,000 애프터 / 제닉스로보틱스 6,150 stale). ⚠️ 애프터마켓 시행(2026-09-14) **전에는 문제가 없었다** — 20:10 스냅샷이 곧 정규장 종가라 KRX 열과 통합 열 baseline 이 우연히 일치했고, 애프터마켓이 그 우연을 깼다. 그래서 `_fetch_all_data` 가 **`_nxt_inactive` 종목의 `pred_close_snap`/`pred_close`/`day_change` 를 KRX 배치시세의 전일종가(`price − change`)로 덮어쓴다** — 실측 아난티 미니표 전일종가 `5,210 | 5,210` 두 열 일치. NXT 상장 종목이 `274,000 | 275,000` 으로 갈리는 건 **의도된 설계**다. ⚠️ **두 지표가 서로 다른 baseline 을 쓰는 것이 정답이다** — 미니표 등락률은 시장 표기와 맞아야 하니 **공식 전일종가(15:30)**, 당일 평가손익(`prev_net`)은 '어제 장 마감 대비 내 자산 변동'이니 **애프터 종가(20:00)**(`correct_nxt_inactive_rows` 가 스냅샷 `price`/`eval_value` 를 KRX 최신가로 교체해 이쪽을 통일). **한쪽으로 합치려 하지 말 것.** ⚠️ `_nxt_inactive` 판정은 전날 스냅샷 목록만 믿으면 안 된다 — 목록이 낡거나 신규 종목이면 통합 열에 엉뚱한 baseline 이 들어가므로 `not _is_nxt_traded(code)` 를 함께 본다(캐시 미스 시 True 라 미지 종목을 잠그지 않는다). **시장 phase 전환 재렌더**(2026-08-25): 2026-07-22 이벤트 기반 전환으로 주기적 패널 swap 이 사라진 뒤, `auto_reload_script` 의 `/api/market_state` 폴링이 **유일한 시각 기반 재렌더 트리거**다. ⚠️ 전환 판정을 `active` 불리언으로 하면 안 된다 — `regular`·`nxt` 둘 다 `active:true` 라 **09:00·15:30 전환이 아예 감지되지 않고**, 체결·탭전환이 없으면 거래가능 초록점(`.px-dot.tradable`)·OHLC 활성 컬럼이 무기한 stale 로 굳는다. 그래서 `phase` 문자열을 함께 비교한다. ⚠️ 전환 시 재렌더는 **`__behive_load(null,true)` fresh 필수** — 패널 캐시(10s)가 전환 직전 HTML 을 돌려주면 그걸 고칠 다음 트리거가 없다. ⚠️ 폴링은 `setInterval` 고정 60초가 아니라 **`_secs_to_next_phase()`(응답 `next_in`)로 다음 경계 +2초에 맞춰 재예약하는 `setTimeout` 체인**이다(고정 주기면 경계와 최대 60초 어긋남). `now.second` 절사라 값이 항상 실제보다 크거나 같아 타이머가 경계보다 일찍 깨지 않는다. 숨김 상태에선 폴링을 건너뛰므로 `visibilitychange` 복귀 시 300ms 뒤 1회 확인. **허브 종료 시 실시간 스냅샷 폐기**(2026-09-10): ⚠️ `/api/realtime/quotes` 폴링이 `connected:false` 를 받고 **그냥 return 하면 안 된다** — 마지막 응답이 `connected:true` 인 채 `window.__rtState` 에 남고, `behive:panels-loaded` 훅의 `applyState(__rtState)` 가 `d.connected`(옛 true)만 보고 통과해 **서버가 방금 조회한 값을 옛 틱으로 덮어쓴다**. 20:00 NXT 마감 → 허브 종료(장마감 확인이 30초 주기라 20:00:00~20:00:30) 뒤에는 이 경로 때문에 **phase 전환 재렌더를 해도 화면이 19:5x 값에 고착**됐다(실측: swap 후 종목 행·owner KPI 는 옛 rt, 계좌별 KPI 는 서버 REST 라 한 화면에서 3,848,000원 갈림). 그래서 끊기면 `__rtState`·`__rtPx` 를 **null 로 버린다** — 허브가 없으면 REST 가 진실이다. ⚠️ **20:00 이후엔 서버 재렌더 트리거가 사실상 없다** — `holdings_seq` 폴링은 장외 스킵, `fill_seq` 감지는 `connected:false` 면 tick 이 먼저 return. 수동 새로고침·PTR·페이지 재진입이 전부다. ⚠️ **계좌별 KPI(`_render_account_kpi`)는 여전히 실시간 갱신 대상이 아니다**(`setOwnerKpis` 가 `[data-rt-owner]` 카드만 갱신) — 장중엔 owner 합계(rt 기준)와 계좌별 합(서버 렌더 시점)이 상시 어긋난다. 2026-09-10 관리자님 판단으로 이쪽은 손대지 않았다. **KPI 라벨 규칙**(2026-07-29): **'총'은 계좌 전체(현금 포함) 기준일 때만 쓴다** — `총자산`(=평가금액+예수금)·`총수익`(=총자산−투자원금)만 '총'을 갖고, 보유종목 한정인 `평가금액`·`매입금액`·`평가손익`은 안 붙인다. 이름만으로 관계식이 읽히게 한 정리(구 라벨: 총 평가금액→평가금액, 순자산→**총자산**, 총 매입금액→매입금액, 총 평가손익→평가손익, `총수익 (원금 대비)`→총수익). 헷갈리던 세 쌍을 분리한 결과 — ①평가금액 vs 총자산(차이=예수금) ②매입금액 vs 투자원금(전자는 현 보유분 매입원가, 후자는 실제 입금액) ③평가손익 vs 총수익(전자는 보유분 미실현만, 후자는 실현·배당 포함). `_render_owner_kpi`·`_render_account_kpi`·**`stock_portfolio_report`(HTML·평문·텔레그램)**·순자산 차트 표시문(`NET_WORTH_MODE_PRESETS`·차트 제목·aria·툴팁 행) 전부 통일. ⚠️ 평문 리포트 라벨은 **표시폭 17칸 정렬**(한글 2칸) — 라벨 바꿀 때 공백 수 재계산 필요. ⚠️ `_rtk` 실시간 매핑 키가 라벨 문자열이라 라벨 변경 시 함께 고쳐야 한다. ⚠️ PBR 설명문의 '회사 순자산'은 뜻이 다르니 건드리지 않는다. ⚠️ `render`는 **셸 HTML만** 생성(데이터 fetch 없음) — 패널 내용 검증은 `_build_panels_payload()` 직접 호출이나 `/api/panels` curl로 해야 한다. **KPI 행 순서**(2026-07-29, 관리자님 지정): 합산 owner 카드(`_render_owner_kpi`)는 **구분선·그룹 없는 단일 목록** — `총자산 · **투자원금** · 매입금액 · 평가금액 · 평가손익 · 예수금 · [당일 입출금] · 보유종목/당일매매 · 당일 평가손익` + 조건부 `당일 실현손익`(non-compact) + 맨 아래 `총수익`. `당일 입출금`은 예수금 바로 아래(현금 잔액↔그날 현금 이동)이고 입출금 없는 날은 행 자체가 없다. 투자원금은 총자산 바로 아래(전체 자산↔넣은 돈 대비), 총수익은 맨 아래 결론. 원금 미집계(부트스트랩 전)면 **두 행 모두** 사라진다. 보유종목·당일매매는 **한 행 결합**(`3개 / 2개`) — compact에도 당일매매가 함께 보인다. 평문 리포트에서 `보유종목/당일매매`는 표시폭이 정확히 17칸이라 패딩 없이 콜론이 맞는다. 같은 순서·라벨을 `_render_account_kpi`와 `stock_portfolio_report`(HTML 소유자별·전체합계, 평문)에도 적용. ⚠️ 2026-07-29 중 4그룹(자산/보유/누적/당일) 구분선 방식을 거쳤다가 단일 목록으로 되돌림 — `.kpi-gtop` CSS는 제거됨. `_rtk` 실시간 매핑(value/profit/net/daypl/totret)은 라벨 기준이라 그룹화와 무관하게 동작. 계좌별 pane(`_render_account_kpi`)은 평면 유지(관리자님 선택). ⚠️ 그룹화로 stock_portfolio_report 메일의 평면 순서와는 배치가 갈렸다(라벨은 동일). **투자원금·총수익 KPI**(2026-07-29): `누적 성과` 그룹 2행 — `투자원금`(값은 금액만, **라벨 옆 작은 원형 `[!]` 버튼**) / `총수익`. 버튼을 값(td)이 아니라 라벨(th)에 두는 이유 — 금액 뒤에 붙이면 숫자 우측정렬이 흐트러진다. `kpis` 튜플의 **3번째 원소**가 라벨 뒤 HTML(escape 대상 아님)로 렌더된다. **KPI 상세 팝업은 공용 1개**(`kpi-modal` + `openKpiModal(src,title)`): 라벨 옆 `.kpi-more-btn`이 `data-kpi-src`(엔드포인트)·`data-kpi-title`을 들고 있고, 위임 핸들러가 그걸 읽어 fetch → 본문 innerHTML. 버튼 생성은 `_kpi_more_button(src, title)`. 본문은 **서버가 HTML로 조립**하므로 새 [!] 버튼을 늘릴 때 JS·모달 추가가 필요 없다 — 렌더 함수 + 엔드포인트 분기만 더하면 된다. 현재 4개: `/api/surge?code=&name=`(`_render_surge_body` — 급등락 알림 구간·오늘 소진·on/off 토글, 아래 참조) / `/api/deposit?owner=` / `/api/principal?owner=`(`_render_principal_body` — 투자원금·누적 입금/출금·계좌별 + 입출금 내역 233행) / `/api/cashflow?owner=`(`_render_cashflow_body` — 당일 입금/출금 합계 + 건별 내역, kt00015 계좌당 1콜 재조회). 두 팝업 모두 `.pm-*` CSS 공유. ⚠️ 순액과 합계가 같아지는 중복 표시 금지 — 당일 입출금은 **한쪽 방향뿐이면 `순 입출금` 행을 생략**한다(`+3,000,000원 · 입금 3,000,000원` 같은 군더더기가 KPI 행·메일·텔레그램 4곳에 있었음, 2026-07-29 제거). 모달 셸은 shell HTML 직속이라 panels swap 영향 없음. 내역은 `ip.get_flows` 캐시 읽기라 **키움 콜 0**. 내역 233행 중 대부분이 배당·이자라 실제 입금이 묻히므로 **기본은 원금 반영분만 보이고**(본인 49/140·가희 15/93) 체크박스로 나머지를 펼친다 — `.pm-all-cb:not(:checked) ~ .pm-list .pm-row.other{display:none}` **CSS-only, JS 없음**. 개념 설명문은 넣지 않는다(관리자님 지시). ⚠️ 계좌 간 대체(내 일반↔ISA)가 있으면 owner 합계는 상쇄되지만 **계좌별 금액은 왜곡**되므로 안내문(`.pm-note-in`)을 띄운다(실측 본인 6,000,000·가희 1,000,000 각각 상쇄). `순자산 − 원금` 관계가 눈으로 읽히게 붙여 놓고, 위쪽 `총 평가손익`(보유분 미실현)과 혼동되지 않게 라벨에 "원금 대비"를 명시. 데이터는 `investment_principal.get_principal(lbls)`(캐시 읽기, 콜 0) → `_build_owner_data`가 `principal`·`principal_cash_in/out`·`principal_qty_flow`로 주입, None이면 2행 생략. 순자산이 WS 틱마다 움직이므로 총수익도 같이 갱신 — 카드 `data-rt-principal` + td `data-rt-kpi="totret"`를 `setOwnerKpis`가 읽어 `T.net − principal` 재계산(`updateScopes`/`T` 구조 무변경). 계좌별 pane(`_render_account_kpi`)은 미적용. 보유종목 행마다 `📋 거래내역` + `💰 거래` + `📝 메모` 버튼. 자산정보 탭 sub-tab 3개 (자산보기·차트보기·시장정보) + 우측 별도 `[💰 거래]` 버튼 (본인 첫 보유종목 자동 선택). 관심·감시종목 행에도 `💰 거래` (보유 모드면 매도 default). +- `behive_web.py` — 워치리스트 실시간 웹 뷰 + 매매 진입점. `serve`(launchd, Tailscale IP 100.75.148.12:18790 바인드, 페이지 GET마다 키움 ka10095 batch 1콜로 워치리스트 시세 + kt00018·kt00001·ka10170 병렬 호출 후 HTML 응답. RENDER 캐시 10s, 종목별 quote 캐시 30s) / `render`(디버깅용 1회 렌더). 외부 노출은 NAS Synology reverse proxy(`stock.hyowons.net` → mac:18790) 경유. 인증 없음 — Tailnet 내부망 한정 운영. **빌드 버전 자동 리로드**(2026-07-03): 이 앱은 `location.reload` 없이 fetch로만 갱신해 코드 수정+재시작 후에도 열려있던 페이지는 옛 인라인 JS/CSS를 계속 씀. `BUILD`(파일 mtime, import 시 1회) 상수를 shell `window.__build`와 `/api/panels` 응답 `build`에 실어, `apply()`가 불일치(옛 페이지=`__build` 없음 포함) 감지 시 `sessionStorage` 가드로 build당 1회 자동 리로드 → 최신 JS/CSS 반영(리로드로도 안 맞으면 루프 없이 포기). 보유종목 day_change 보정은 brifing 과 동일 A-4 정책. **ka10095 를 KRX+AL 2종으로 통일**(2026-09-10): ⚠️ 예전엔 보유(KRX/NX/AL 3콜)와 감시·관심(AL + NX probe)이 제각각이었고 NXT 단독 조회가 곳곳에 있었다. 지금은 **NXT 단독 조회가 없다** — ①현재가 보정은 AL 하나로(AL 이 곧 '지금 활성 시장의 최신 체결'이라 예전의 phase별 분기 regular=KRX/nxt=NXT 가 통째로 사라짐) ②RVOL 의 KRX 거래량은 KRX 를 직접 조회(예전 `AL−NXT` 역산은 두 조회의 시점차만큼 오차가 났다 — 실측 삼성전자 14주) ③NXT 거래 여부는 `_is_nxt_traded()` = `state/stock_codes.json` 의 `nxt_enable`(**키움 콜 0**, 코드 키 역인덱스 5분 캐시 `_nxt_enable_map`). ⚠️ `kc.lookup_stock_meta` 를 행마다 부르면 안 된다 — 호출마다 954KB JSON 을 다시 읽고 3500종목을 순회해 100종목이면 렌더가 멈춘다. ⚠️ KRX 보정 조회는 **`nxt_enable=True` 종목만** 한다 — NXT 미거래 종목은 AL 이 곧 KRX 값이라 조회해도 같은 값이 온다(101종목 전수 실측 예외 0건). 종목 수가 늘어도 성립하는 규칙이라 '몇 종목이니 몇 콜'식 가정에 기대지 않는다. **실측 렌더당 ka10095 10콜 → 7콜**(감시·관심 101종목이 40개 배치 한도로 3콜씩 쪼개지는 게 대부분이다 — 콜 수를 셀 때 이 분할을 빠뜨리기 쉽다). ⚠️ 초록점 판정이 meta 기반이 되면서 **계좌별 행에도 초록점이 붙는다**(예전엔 `ohlc_nxt` 가 consolidated 행에만 주입돼 계좌별 행에서 조용히 빠졌다). **듀얼 OHLC 미니표는 `KRX | 통합`**(2026-09-10, 관리자님 결정): 예전 `KRX | NXT` 에서 두 번째 열을 통합으로 교체. NXT 시간대엔 통합 = NXT 최신 체결이라 가격 4값이 같고 거래량만 합산이며, 정규장엔 통합이 프리마켓을 포함해 KRX 와 갈린다(실측 삼성전자 시가 269,000 vs 268,000). ⚠️ 통합 열 시가는 **덮어쓰지 않는다** — 예전 NXT 열은 시가 칸에 전일종가를 넣었지만 통합 시가는 NXT 프리마켓 첫 체결이라 그 자체가 정보다. ⚠️ 통합 열 전일종가는 **`r['pred_close']` 를 쓰면 안 된다** — 그 값은 `rebase_to_nxt_close` 가 만드는 '당일 등락 baseline' 이라 **어제 보유가 없던 종목(오늘 신규 매수)엔 매수 평단이 들어간다**(실측 2026-09-10 우리기술: 평단 13,780 이 전일종가 자리에 노출, 실제 전일종가는 14,320). 그래서 rebase **직전에** 어제 스냅샷 종가를 `pred_close_snap` 으로 따로 보존하고, 미니표는 ①`pred_close_snap`(어제 보유분 = 어제 NXT 종가) ②없으면 ka10095 AL 의 `현재가 − 전일대비` 순으로 쓴다. ⚠️ 기존 NXT 열에도 같은 코드였지만 'NXT 미거래 + 오늘 신규매수' 두 조건이 겹쳐야 보이는 자리(NXT 미거래면 열이 통째로 비었다)라 드러나지 않았다. ⚠️ 보유행 '당일 등락'은 여전히 평단 기준이다 — 오늘 산 종목을 어제 종가와 비교하는 건 손익 관점에서 무의미하므로 의도된 동작이고, 미니표(시장 데이터)와 목적이 다르다. **정규장(09:00~15:30)엔 KRX 기준**(2026-10-02 관리자님 지적): 미니표 강조 열·KRX 열 현재가·카드 요약 캔들을 KRX 로 둔다(그 외 거래시간은 통합). 실시간 틱은 `_AL` 뿐이라 정규장 KRX 현재가 셀엔 `data-rt-ohlc-cur` 를 달지 않는다 — 달면 통합 틱이 덮어쓴다(그래서 그 셀은 렌더 시점 값). ⚠️ 미니표 전환은 **콜 절감이 목적이 아니다**(보유 NX 콜은 어차피 감시·관심 NXT 조회에 얹으면 공짜였다) — 화면 일관성 때문에 선택한 것. **감시·관심 카드 미니표도 같은 라벨**(2026-09-11): `_render_ohlc_mini` 는 구조가 다르다 — 가격은 통합(AL) 하나이고 **등락률만 두 기준**으로 나뉜다(①KRX 전일종가 ②어제 통합 마지막 체결 = `_watchlist_nxt` 스냅샷의 NXT 종가). 보유행 `통합` 열과 같은 개념이라 값·데이터원은 그대로 두고 헤더 라벨만 'NXT'→'통합' 으로 맞췄다. ⚠️ 보유행과 달리 **폴백(없으면 KRX 전일종가)을 두지 않는다** — 두 컬럼이 같은 값이 되어 중복이다(NXT 미운영 종목은 어제 종가가 곧 KRX 종가라 원래 두 값이 같다). ⚠️ 320px 에서 미니표가 6px 넘치는 건 라벨과 무관한 기존 동작이다(라벨을 'NXT' 로 되돌려도 동일, 컬럼 폭 56px 불변 — 360px 이상은 넘침 0). **시장 phase 4종**(2026-09-21 `after` 신설): `regular`(09:00~15:30) / `nxt`(08:00~09:00 프리 + 15:30~16:00) / **`after`(16:00~20:00 = KRX 애프터마켓 + NXT 애프터마켓)** / `closed`·`holiday`·`weekend`. ⚠️ **KRX 애프터마켓 시행(2026-09-14)으로 NXT 미상장 종목도 16:00 이후 거래된다** — 한 값으로 뭉쳐 두면 `nxt_enable=False` 인 2,600여 종목이 부당하게 잠긴다(실측 보유 16·관심 26·감시 46종목이 '값은 갱신되는데 버튼은 잠긴' 상태였다). ⚠️ **`after` 추가 시 `== 'nxt'` 비교 소비처를 전부 같이 고쳐야 한다** — 안 고치면 else 로 떨어져 실시간 가격 보정(`active_map`)·거래가능 초록점 2곳·OHLC 활성열이 **조용히 죽는다**. 그래서 거래시간 판정은 `_PHASE_TRADING` 상수로, 종목별 거래가능은 **`_phase_tradable(phase, code)` 한 함수로 모았다**(예전엔 미리보기 행과 보유행에 같은 if/elif 가 복제돼 있어 한쪽만 고치면 같은 종목이 화면 두 곳에서 다르게 보였다). ⚠️ **애프터마켓 차단 대상은 ETF/ETN 뿐**이고 판별은 `stock_codes.json` 의 `etf_etn` 플래그다(2026-09-21 실측: TIGER 200 은 15:35 이후 거래량·현재가·호가잔량이 **완전 고정**). 제외종목 9종(거래정지·투자경고·이상급등 등)은 판별 필드가 없어 완전 차단이 불가능하다 — 막을 수 있는 것만 막고 나머지는 키움 거부 메시지로 드러낸다. ⚠️ 15:30~16:00 은 `nxt` 로 남긴다(KRX 쪽은 시간외 종가매매뿐이고 우리는 `trde_tp=81` 을 쓰지 않는다). ⚠️ 호가·현재가는 애프터마켓엔 **KRX(bare)** 를 쓴다 — KRX 애프터마켓 호가가 bare 조회로 그대로 온다(실측 삼성전자우 매도1/매수1 이 16:05·17:00·18:00 모두 갱신). ⚠️ **KRX OHLC 열의 '종가' 표시는 15:30~16:00 과 20:00 이후만**이다 — 애프터마켓엔 KRX 가 접속매매로 다시 돌아 가격이 변하므로, 예전처럼 15:30 이후 전부 종가로 두면 '종가'라 써놓고 값이 계속 바뀐다. **애프터마켓 종가 ≠ 공식 전일종가**(2026-09-22 실측): 오늘의 전일종가는 **어제 15:30 정규장 종가**다(7종목 전수 — 삼성전자우 정규장 208,000 / 애프터 마지막 210,500 → 오늘 전일종가 208,000). 그런데 `stock_portfolio_report` 는 스냅샷을 **20:10** 에 찍고 kt00018 을 **`exchange='NXT'`** 로 조회해서, NXT **상장** 종목엔 애프터 종가가 NXT **미거래** 종목엔 **stale**(정규장 종가)이 들어간다 — **한 스냅샷에 두 기준이 섞인다**(어제 실측: 삼성전자 275,000 애프터 / 제닉스로보틱스 6,150 stale). ⚠️ 애프터마켓 시행(2026-09-14) **전에는 문제가 없었다** — 20:10 스냅샷이 곧 정규장 종가라 KRX 열과 통합 열 baseline 이 우연히 일치했고, 애프터마켓이 그 우연을 깼다. 그래서 `_fetch_all_data` 가 **`_nxt_inactive` 종목의 `pred_close_snap`/`pred_close`/`day_change` 를 KRX 배치시세의 전일종가(`price − change`)로 덮어쓴다** — 실측 아난티 미니표 전일종가 `5,210 | 5,210` 두 열 일치. NXT 상장 종목이 `274,000 | 275,000` 으로 갈리는 건 **의도된 설계**다. ⚠️ **두 지표가 서로 다른 baseline 을 쓰는 것이 정답이다** — 미니표 등락률은 시장 표기와 맞아야 하니 **공식 전일종가(15:30)**, 당일 평가손익(`prev_net`)은 '어제 장 마감 대비 내 자산 변동'이니 **애프터 종가(20:00)**(`correct_nxt_inactive_rows` 가 스냅샷 `price`/`eval_value` 를 KRX 최신가로 교체해 이쪽을 통일). **한쪽으로 합치려 하지 말 것.** ⚠️ `_nxt_inactive` 판정은 전날 스냅샷 목록만 믿으면 안 된다 — 목록이 낡거나 신규 종목이면 통합 열에 엉뚱한 baseline 이 들어가므로 `not _is_nxt_traded(code)` 를 함께 본다(캐시 미스 시 True 라 미지 종목을 잠그지 않는다). **시장 phase 전환 재렌더**(2026-08-25): 2026-07-22 이벤트 기반 전환으로 주기적 패널 swap 이 사라진 뒤, `auto_reload_script` 의 `/api/market_state` 폴링이 **유일한 시각 기반 재렌더 트리거**다. ⚠️ 전환 판정을 `active` 불리언으로 하면 안 된다 — `regular`·`nxt` 둘 다 `active:true` 라 **09:00·15:30 전환이 아예 감지되지 않고**, 체결·탭전환이 없으면 거래가능 초록점(`.px-dot.tradable`)·OHLC 활성 컬럼이 무기한 stale 로 굳는다. 그래서 `phase` 문자열을 함께 비교한다. ⚠️ 전환 시 재렌더는 **`__behive_load(null,true)` fresh 필수** — 패널 캐시(10s)가 전환 직전 HTML 을 돌려주면 그걸 고칠 다음 트리거가 없다. ⚠️ 폴링은 `setInterval` 고정 60초가 아니라 **`_secs_to_next_phase()`(응답 `next_in`)로 다음 경계 +2초에 맞춰 재예약하는 `setTimeout` 체인**이다(고정 주기면 경계와 최대 60초 어긋남). `now.second` 절사라 값이 항상 실제보다 크거나 같아 타이머가 경계보다 일찍 깨지 않는다. 숨김 상태에선 폴링을 건너뛰므로 `visibilitychange` 복귀 시 300ms 뒤 1회 확인. **허브 종료 시 실시간 스냅샷 폐기**(2026-09-10): ⚠️ `/api/realtime/quotes` 폴링이 `connected:false` 를 받고 **그냥 return 하면 안 된다** — 마지막 응답이 `connected:true` 인 채 `window.__rtState` 에 남고, `behive:panels-loaded` 훅의 `applyState(__rtState)` 가 `d.connected`(옛 true)만 보고 통과해 **서버가 방금 조회한 값을 옛 틱으로 덮어쓴다**. 20:00 NXT 마감 → 허브 종료(장마감 확인이 30초 주기라 20:00:00~20:00:30) 뒤에는 이 경로 때문에 **phase 전환 재렌더를 해도 화면이 19:5x 값에 고착**됐다(실측: swap 후 종목 행·owner KPI 는 옛 rt, 계좌별 KPI 는 서버 REST 라 한 화면에서 3,848,000원 갈림). 그래서 끊기면 `__rtState`·`__rtPx` 를 **null 로 버린다** — 허브가 없으면 REST 가 진실이다. ⚠️ **20:00 이후엔 서버 재렌더 트리거가 사실상 없다** — `holdings_seq` 폴링은 장외 스킵, `fill_seq` 감지는 `connected:false` 면 tick 이 먼저 return. 수동 새로고침·PTR·페이지 재진입이 전부다. ⚠️ **계좌별 KPI(`_render_account_kpi`)는 여전히 실시간 갱신 대상이 아니다**(`setOwnerKpis` 가 `[data-rt-owner]` 카드만 갱신) — 장중엔 owner 합계(rt 기준)와 계좌별 합(서버 렌더 시점)이 상시 어긋난다. 2026-09-10 관리자님 판단으로 이쪽은 손대지 않았다. **KPI 라벨 규칙**(2026-07-29): **'총'은 계좌 전체(현금 포함) 기준일 때만 쓴다** — `총자산`(=평가금액+예수금)·`총수익`(=총자산−투자원금)만 '총'을 갖고, 보유종목 한정인 `평가금액`·`매입금액`·`평가손익`은 안 붙인다. 이름만으로 관계식이 읽히게 한 정리(구 라벨: 총 평가금액→평가금액, 순자산→**총자산**, 총 매입금액→매입금액, 총 평가손익→평가손익, `총수익 (원금 대비)`→총수익). 헷갈리던 세 쌍을 분리한 결과 — ①평가금액 vs 총자산(차이=예수금) ②매입금액 vs 투자원금(전자는 현 보유분 매입원가, 후자는 실제 입금액) ③평가손익 vs 총수익(전자는 보유분 미실현만, 후자는 실현·배당 포함). `_render_owner_kpi`·`_render_account_kpi`·**`stock_portfolio_report`(HTML·평문·텔레그램)**·순자산 차트 표시문(`NET_WORTH_MODE_PRESETS`·차트 제목·aria·툴팁 행) 전부 통일. ⚠️ 평문 리포트 라벨은 **표시폭 17칸 정렬**(한글 2칸) — 라벨 바꿀 때 공백 수 재계산 필요. ⚠️ `_rtk` 실시간 매핑 키가 라벨 문자열이라 라벨 변경 시 함께 고쳐야 한다. ⚠️ PBR 설명문의 '회사 순자산'은 뜻이 다르니 건드리지 않는다. ⚠️ `render`는 **셸 HTML만** 생성(데이터 fetch 없음) — 패널 내용 검증은 `_build_panels_payload()` 직접 호출이나 `/api/panels` curl로 해야 한다. **KPI 행 순서**(2026-07-29, 관리자님 지정): 합산 owner 카드(`_render_owner_kpi`)는 **구분선·그룹 없는 단일 목록** — `총자산 · **투자원금** · 매입금액 · 평가금액 · 평가손익 · 예수금 · [당일 입출금] · 보유종목/당일매매 · 당일 평가손익` + 조건부 `당일 실현손익`(non-compact) + 맨 아래 `총수익`. `당일 입출금`은 예수금 바로 아래(현금 잔액↔그날 현금 이동)이고 입출금 없는 날은 행 자체가 없다. 투자원금은 총자산 바로 아래(전체 자산↔넣은 돈 대비), 총수익은 맨 아래 결론. 원금 미집계(부트스트랩 전)면 **두 행 모두** 사라진다. 보유종목·당일매매는 **한 행 결합**(`3개 / 2개`) — compact에도 당일매매가 함께 보인다. 평문 리포트에서 `보유종목/당일매매`는 표시폭이 정확히 17칸이라 패딩 없이 콜론이 맞는다. 같은 순서·라벨을 `_render_account_kpi`와 `stock_portfolio_report`(HTML 소유자별·전체합계, 평문)에도 적용. ⚠️ 2026-07-29 중 4그룹(자산/보유/누적/당일) 구분선 방식을 거쳤다가 단일 목록으로 되돌림 — `.kpi-gtop` CSS는 제거됨. `_rtk` 실시간 매핑(value/profit/net/daypl/totret)은 라벨 기준이라 그룹화와 무관하게 동작. 계좌별 pane(`_render_account_kpi`)은 평면 유지(관리자님 선택). ⚠️ 그룹화로 stock_portfolio_report 메일의 평면 순서와는 배치가 갈렸다(라벨은 동일). **투자원금·총수익 KPI**(2026-07-29): `누적 성과` 그룹 2행 — `투자원금`(값은 금액만, **라벨 옆 작은 원형 `[!]` 버튼**) / `총수익`. 버튼을 값(td)이 아니라 라벨(th)에 두는 이유 — 금액 뒤에 붙이면 숫자 우측정렬이 흐트러진다. `kpis` 튜플의 **3번째 원소**가 라벨 뒤 HTML(escape 대상 아님)로 렌더된다. **KPI 상세 팝업은 공용 1개**(`kpi-modal` + `openKpiModal(src,title)`): 라벨 옆 `.kpi-more-btn`이 `data-kpi-src`(엔드포인트)·`data-kpi-title`을 들고 있고, 위임 핸들러가 그걸 읽어 fetch → 본문 innerHTML. 버튼 생성은 `_kpi_more_button(src, title)`. 본문은 **서버가 HTML로 조립**하므로 새 [!] 버튼을 늘릴 때 JS·모달 추가가 필요 없다 — 렌더 함수 + 엔드포인트 분기만 더하면 된다. 현재 4개: `/api/surge?code=&name=`(`_render_surge_body` — 급등락 알림 구간·오늘 소진·on/off 토글, 아래 참조) / `/api/deposit?owner=` / `/api/principal?owner=`(`_render_principal_body` — 투자원금·누적 입금/출금·계좌별 + 입출금 내역 233행) / `/api/cashflow?owner=`(`_render_cashflow_body` — 당일 입금/출금 합계 + 건별 내역, kt00015 계좌당 1콜 재조회). 두 팝업 모두 `.pm-*` CSS 공유. ⚠️ 순액과 합계가 같아지는 중복 표시 금지 — 당일 입출금은 **한쪽 방향뿐이면 `순 입출금` 행을 생략**한다(`+3,000,000원 · 입금 3,000,000원` 같은 군더더기가 KPI 행·메일·텔레그램 4곳에 있었음, 2026-07-29 제거). 모달 셸은 shell HTML 직속이라 panels swap 영향 없음. 내역은 `ip.get_flows` 캐시 읽기라 **키움 콜 0**. 내역 233행 중 대부분이 배당·이자라 실제 입금이 묻히므로 **기본은 원금 반영분만 보이고**(본인 49/140·가희 15/93) 체크박스로 나머지를 펼친다 — `.pm-all-cb:not(:checked) ~ .pm-list .pm-row.other{display:none}` **CSS-only, JS 없음**. 개념 설명문은 넣지 않는다(관리자님 지시). ⚠️ 계좌 간 대체(내 일반↔ISA)가 있으면 owner 합계는 상쇄되지만 **계좌별 금액은 왜곡**되므로 안내문(`.pm-note-in`)을 띄운다(실측 본인 6,000,000·가희 1,000,000 각각 상쇄). `순자산 − 원금` 관계가 눈으로 읽히게 붙여 놓고, 위쪽 `총 평가손익`(보유분 미실현)과 혼동되지 않게 라벨에 "원금 대비"를 명시. 데이터는 `investment_principal.get_principal(lbls)`(캐시 읽기, 콜 0) → `_build_owner_data`가 `principal`·`principal_cash_in/out`·`principal_qty_flow`로 주입, None이면 2행 생략. 순자산이 WS 틱마다 움직이므로 총수익도 같이 갱신 — 카드 `data-rt-principal` + td `data-rt-kpi="totret"`를 `setOwnerKpis`가 읽어 `T.net − principal` 재계산(`updateScopes`/`T` 구조 무변경). 계좌별 pane(`_render_account_kpi`)은 미적용. 보유종목 행마다 `📋 거래내역` + `💰 거래` + `📝 메모` 버튼. 자산정보 탭 sub-tab 3개 (자산보기·차트보기·시장정보) + 우측 별도 `[💰 거래]` 버튼 (본인 첫 보유종목 자동 선택). 관심·감시종목 행에도 `💰 거래` (보유 모드면 매도 default). **거래 모달 시스템 (`order-modal` + `pin-modal` + `open-orders-modal`)** — 매매 진입점. 흐름: 종목 select(상단, 보유/관심/감시 통합) → 매수·매도 토글 → 호가창(ka10004 10단계, 1초 polling, visibility 가드) + 입력(계좌·주문유형 LIMIT/MARKET·단가·금액(매수만 양방향)·수량) → 매수/매도 버튼 → propose → PIN 모달(modal-top z-index) 카드 요약 + PIN 입력(`autocomplete="one-time-code"`) + 만료 카운트다운 → verify → 결과 토스트 + 자동 닫기. `[📋 진행중]` 탭은 활성 PIN 카드 → PIN 모달, 미체결만 → open-orders 모달(4계좌 통합 + 행별 취소·가격 정정). **가격 정정**(2026-09-11): 행의 `정정` 버튼(↔`접기` 토글)이 행 안에 인라인 입력줄을 펼친다 — 기존 주문가 prefill·`snapToTick` 호가 자동 보정(키움 거부 메시지를 보기 전에 맞춘다)·Enter 제출·확인 팝업 1회 → `POST /api/orders/modify`. **PIN 없음**(취소와 같은 정책 — 이미 승인된 주문의 가격만 바뀌고 수량 증가 경로가 없다). **가드는 신규 주문과 같은 것을 태운다**(2026-09-11) — `guards.validate_price_band`(±30% 제한폭)와, 매수면 `guards.validate_balance_for_buy`(예수금). propose 만 막아두면 정정이 그 검증을 우회하는 뒷문이 되기 때문이다. ⚠️ **예수금은 되더해서 비교한다** — `datasource.collect_market_data` 의 `balance_d2` 는 미체결 매수로 묶인 돈을 **정정 대상 주문까지 포함해** 뺀 값이라, 그 주문 몫(`order_price × unfilled_qty`)을 되돌려 더하지 않으면 **제 주문에 제가 막힌다**(149,000×2 로 묶인 상태에서 149,100 으로 올리는 것조차 '예수금 부족'). 되더하는 식은 datasource 의 locked 계산과 **같아야** 정확히 상쇄된다. ⚠️ 매도 보유수량(`validate_position_for_sell`)은 태우지 않는다 — 이미 접수된 주문의 수량을 그대로 두거나 줄이는 경로라 새로 필요한 보유분이 없다. ⚠️ **`collect_market_data` 를 통째로 재사용해 정정 1회에 키움 5~6콜**이 나간다(호가 ka10004·체결 kt00007 은 정정엔 불필요하지만 함수를 쪼개지 않았다). 수동·저빈도 조작이라 수용한 트레이드오프. ⚠️ 시세 조회가 실패하면 `upper_limit=0` 이라 **모든 정정이 거부**된다(propose 와 같은 보수적 실패). ⚠️ **수량은 웹에서 열지 않았다**(가격만 보낸다) — 다만 백엔드는 qty 를 받으므로 CLI·텔레그램의 수량 증가도 이제 같은 예수금 검증을 받는다. ⚠️ **스톱지정가·트레일링 레그는 정정 불가** — `handler.modify_open_order` 가 `row['stop_price']>0` 이면 **전 경로에서**(웹·텔레그램 공통) 거부한다. 두 가지가 동시에 깨지기 때문이다: ①`mdfy_cond_uv`(조건단가)를 안 넘겨 조건이 빈 값으로 나간다 ②정정 응답의 `ord_no` 는 **신규 주문번호**인데 `trailing_stops.json` 의 레그는 그걸 모르므로 그 레그의 자동 손절선 상향이 이후 전부 실패한다. 바꾸려면 취소 후 재등록. 프런트도 같은 기준(`!r.trailing && !r.stop_price && r.order_price > 0`)으로 버튼 자체를 안 그린다 — 시장가는 가격이 0이라 애초에 정정 불가. ⚠️ **정정 성공 후 `loadOpenOrders()` 재조회는 필수**(ord_no 가 바뀌어 화면의 옛 번호로는 다음 취소·정정이 엉뚱한 곳에 간다). ⚠️ 편집줄에 [취소] 버튼을 두지 않는다 — 오른쪽 주문 [취소]와 말이 겹쳐 오조작을 부른다(접기는 `정정`↔`접기` 토글로). 매수 시 금액↔수량 양방향 자동(programmatic .value, 무한루프 X). 매도 토글 시 수량 자동 100%(max_qty). 시장 phase 라벨 + NXT 시간대+`nxt_enable=false` 시 `📵 NXT 거래불가` + 매수/매도 버튼 disable. 우상단 X 없음 — 하단 [닫기]/[취소] + overlay 클릭. **2계좌 동시 매도**(2026-06-12): 매도 버튼 시 **전량매도(입력 수량=max_qty)** 이고 같은 소유자 그룹(본인 일반↔ISA / 가희끼리)의 다른 계좌에도 같은 종목 보유(`accStatus.trde_able_qty>0`)면 `sell-choice-modal` 팝업 — [두 계좌 모두 매도](두 계좌 모두 매도가능 전량, 단가 동일) / [선택 계좌만] / [취소]. 일부매도는 팝업 없이 단일 진행. 매도 정보영역은 그룹 내 다른 계좌도 보유 시 `보유: 141주 / 전체 280주` 병기. 모두 매도는 `POST /api/order/propose_multi` → `handler.propose_trade_multi`(레그별 독립 검증, SELL+LIMIT/MARKET 한정, 같은 그룹만) → **카드 1장+PIN 1개**(iMessage `매도(2계좌)`)가 두 레그 승인 → verify 시 `_submit_multi_legs`가 레그 독립 제출(idem_hash 계좌별 분리)·독립 fill_watcher, 한 레그 실패해도 나머지 시도(ok=하나라도 접수). @@ -126,6 +126,8 @@ Stock agent (`agents/stock/workspace/scripts/`), run with `python3`: **메모 모달 (`note-modal`)**(2026-06-26) — 보유·관심·감시종목 행 모두에 `📝 메모` 버튼. 클릭 → 팝업 textarea(최대 1000자, 여러 줄)에 기존 메모 prefill → [저장]은 `/notes/set` POST(submit 핸들러가 `note-form`을 fetch로 가로채 **현재 탭 유지** — 네이티브 폼이면 303→`/` 리다이렉트로 자산정보 탭으로 튐) 후 패널 새로고침, [삭제]는 버튼 왼쪽 위 작은 **확인 팝업(예/아니오)** 후 빈 텍스트 submit으로 항목 제거(오삭제 방지, `note-confirm`). 메모 있으면 버튼이 `📝 메모 ●` 노란 강조(`has-note`)+hover 툴팁. 저장소 `state/behive_stock_notes.json`(`{by_code, by_name}`, code 우선→name fallback, `stock_tags`와 동일한 fcntl lock·atomic write). **종목 단위 공용**(계좌 분리 X). 백엔드 `_load/_get/_set_stock_note`·`_note_button_html`·`_render_note_modal`, 태그 모달 패턴 복제. + **공모주 sub-tab**(2026-10-02) — 자산정보 탭 하단 `[공모주]`(4번째). **타이틀 배지**: 헤더 `자산현황` 옆 `#ipo-flag` 텍스트 배지 `[청약]` **하나로 통일**(관리자님 지정 — 사유별로 나누지 않는다, 사유는 툴팁) — ①청약기간 중인데 청약완료 안 한 종목 ②청약완료한 종목의 오늘 상장, 둘 중 하나라도 있으면 표시(관리자님 지정, 클릭 동작 없음, 사유는 title 툴팁). `_ipo_flag()` 가 `/api/panels` 응답 `ipo_flag` 로 실어 `apply()` 가 토글 — 셸은 데이터 fetch 없는 원칙이라 첫 패널 로드 뒤에 뜬다. 종목 카드는 `
` **기본 접힘**(요약줄 = 종목명·시장·청약기간·증권사 수·마감일/완료 배지), 펼침 상태는 `apply()` 의 패널 swap 때 `data-ipo-key` 로 복원(`details.row` 복원과 별도 — 공모주 카드는 종목 행이 아니라 `row` 클래스를 쓰면 종목코드 기반 셀렉터들과 엮인다). ①**오늘청약** = 청약기간(`poStartDate ≤ 오늘 ≤ poEndDate`) 중인 종목 전부를 **증권사별 아이템**(청약일·증거금(최소 수량)·수수료·상장일, 마지막 날 `마감일` 배지)으로 ②**청약완료** = 기록된 종목을 **상장일까지**(미정이면 계속) 표시. `[청약완료]` → 작은 확인 팝업(`.note-confirm` 재사용) → 예 → `POST /ipo/subscribe`(fetch, JSON) → `state/behive_ipo_subscribed.json`(`{ipoCode:{name,po_start,po_end,lcal_date,by:{self|gahee:{broker,at}},interest_added?}}`, fcntl lock+atomic). **사람별 버튼 2개**(2026-10-02 관리자님 지시로 종목 단위 1건 → 사람별로 변경): 아이템마다 `[관리자(청약)] [가희(청약)]`, 중복청약 금지가 1인 기준이라 **사람별 종목당 1건** — 누르면 그 사람의 버튼만 그 종목 모든 증권사에서 `✅ 관리자(완료)` 로 바뀐다(청약한 증권사 카드만 초록 강조, 증권사명은 청약완료 섹션에 — 관리자님 지정 문구). 이미 기록된 사람은 덮어쓰지 않는다. 옛 형식(종목 단위 `broker/at`)은 `_load_ipo_subscribed` 가 관리자 기록으로 읽는다(멜콘 1건 그렇게 이관). 타이틀 [청약] 배지의 미완료 판정은 **한 명이라도 했으면 완료**(관리자님 지정). **되돌리기 버튼 없음**(잘못 눌렀으면 state 파일에서 제거). 데이터원은 `ipo_alert.py` 와 같은 네이버 IPO API — 목록(`ipo_calendar_sync._naver_api_rows`)·상세(`ipo_alert._detail`) 각 30분 캐시, **증거금·수수료는 `ipo_alert._manager_blocks` 를 그대로 import**해 텔레그램 알림과 숫자가 항상 같다(수수료 표 `BROKER_ONLINE_FEES` 도 거기 한 곳). 상세 조회는 청약기간 중·완료 종목만, **키움 콜 0**. ⚠️ 예전 공모주 조회 실패는 `finance.naver.com/sise/ipo.naver` HTML 스크래핑 시절 얘기고 2026-09-30 경 JSON API 로 교체된 뒤엔 정상(실측). 렌더 예외는 `_render_summary_panel` 이 잡아 공모주 패널만 에러 문구. **상장일 관심종목 자동등록**: 청약완료한 종목은 상장일 아침 `ipo_alert.py listing`(launchd `stock.ipo-alert` 08:30, 기존 트리거 재사용 — 새 트리거 없음)이 `behive_web.register_listed_ipo_interests()` 를 불러 관심종목에 넣는다(코드 = ipoCode 앞 `A` 제거, 메모 `공모주 청약 (관리자 대신증권, 가희 하나증권)`). 종목당 1회 — 기록에 `interest_added` 를 남기고, 이미 관심종목이면 성공으로 친다. 상장일 판정은 API 상장 목록 ∪ 기록의 `lcal_date` 라 **네이버 조회가 실패해도 기록으로 시도**한다(그래서 `ipo_alert` 가 소스 실패 시 바로 return 하지 않는다). `--dry-run` 이면 건너뜀. 하단 탭 버튼이 4개가 되면서 `.sub-tabs-bottom .sub-tab` 을 `flex:1 1 0; max-width:110px; nowrap` 으로 바꿨다(360px 에서 '자산보 기'로 꺾이던 것 — 320px 넘침 0 실측). + **급등락 알림 — 표시자 🔔 + `[🔔 알림]` 버튼 + 팝업**(2026-08-04 도입 → **2026-08-06 개편**) — 처음엔 `detail-name` 줄의 **아이콘 전용 토글**이었으나, 관리자님 지시로 **아이콘은 on/off 표시만 하고 조작은 팝업 안으로** 옮겼다(아이콘 하나뿐이라 `title` 툴팁이 유일한 설명이었고 — 모바일엔 hover가 없다 — 오조작 위험도 있었다). 구성 3개: - **표시자**(`_surge_indicator_html`) — `detail-name` 줄 종목명 옆의 **비대화형 ``**. 꺼짐 🔕(opacity 0.45 + grayscale)·켜짐 🔔(초록 배경). ⚠️ **`data-surge-toggle` 을 달지 말 것** — 그 속성이 있어야 위임 핸들러가 잡으므로, 없어야 클릭이 무반응이다. CSS `.detail-name .surge-ind` 에 `cursor/hover/active` 를 주지 않는 것도 같은 이유(누를 수 있어 보이면 안 된다). - **버튼**(`_surge_info_button_html`) — `.actions` 줄 `📝 메모` 옆 `🔔 알림`. `class="kpi-more-btn btn-surge-info"` 라 **기존 kpi-modal 위임 핸들러(`t.closest('.kpi-more-btn')`, document 전역)가 그대로 잡는다 → JS·모달 마크업 추가 0**. 켜짐이면 `has-surge` 강조(메모의 `has-note` 패턴). **꺼진 종목에도 노출**한다 — 켜는 입구가 이 팝업뿐이라서. ⚠️ **`&name=` 을 src 에 실어야 한다** — 꺼진 종목은 토글 저장소에 이름이 없어서 팝업에서 켜면 빈 이름으로 저장되고 알림에 종목코드가 찍힌다(2026-08-06 실측). diff --git a/agents/stock/workspace/AGENTS.md b/agents/stock/workspace/AGENTS.md index d5922962..5805d8c3 100644 --- a/agents/stock/workspace/AGENTS.md +++ b/agents/stock/workspace/AGENTS.md @@ -95,6 +95,7 @@ LLM-LLM 자연어 통신은 프롬프트 인젝션·할루시네이션 증폭 - **키움 조회:** `scripts/kiwoom_client.py {token|accounts|summary|balance|positions}` — 조회 전용 (주문 X) - **일일 포트폴리오:** `scripts/stock_portfolio_report.py {run|send} [--by-account]` — 평일 20:10 launchd, 메일만 발송(텔레그램 요약 없음) - **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/watchlist_monitor.py check` — 장중 15분, LLM 없이 직접 알림 - **워치리스트 웹뷰:** `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/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_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. ## 관리자님 관련 diff --git a/agents/stock/workspace/MEMORY.md b/agents/stock/workspace/MEMORY.md index b579a4a6..00c4fb7a 100644 --- a/agents/stock/workspace/MEMORY.md +++ b/agents/stock/workspace/MEMORY.md @@ -52,7 +52,7 @@ | `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 미호출) | | `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 `/`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-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 고정이라 장중 안 바뀌던 것 수정) | diff --git a/agents/stock/workspace/scripts/behive_web.py b/agents/stock/workspace/scripts/behive_web.py index 381929ae..bc1ebfe1 100644 --- a/agents/stock/workspace/scripts/behive_web.py +++ b/agents/stock/workspace/scripts/behive_web.py @@ -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 = '마감일' 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'✅ {label}(완료)') + continue + acts.append( + f'' + f'' + '' + ) + act = ''.join(acts) + return ( + f'
' + f'
{esc(broker)}{badge}
' + '
' + f'
청약일
{esc(_ipo_period(po_s, po_e))}
' + f'
증거금
{esc(_ipo_deposit_text(b))}
' + f'
수수료
{esc(fee)} (온라인 일반)
' + f'
상장일
{esc(_ipo_md(lcal) or "미정")}
' + '
' + f'
{act}
' + '
' + ) + + +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 '
공모주 정보를 불러올 수 없습니다.
' + 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 += '마감일' + for who in (subscribed.get(code) or {}).get('by') or {}: + tags += f'✅ {IPO_WHO.get(who, who)}' + today_parts.append( + f'
' + f'{esc(name)} {esc(r.get("marketType") or "")}' + f' · {esc(_ipo_period(r.get("poStartDate") or "", r.get("poEndDate") or ""))} · {len(blocks)}곳' + f'{tags}{items}
' + ) + today_html = ''.join(today_parts) or '
오늘 청약 중인 공모주가 없습니다.
' + + # ② 청약완료 — 상장일이 지나면 숨김(미정이면 계속 표시). 상장일은 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'
{IPO_WHO.get(who, who)}
{esc(v.get("broker") or "")} · {esc(_ipo_deposit_text(b))}' + f' {esc((v.get("at") or "")[5:16].replace("T", " "))}
') + done_parts.append( + f'
' + f'{esc(rec.get("name") or code)}' + f' · {esc("·".join(IPO_WHO.get(w, w) for w in rec["by"]))} · 상장 {esc(_ipo_md(lcal) or "미정")}' + '' + '
' + + who_rows + + f'
청약일
{esc(_ipo_period(r.get("poStartDate") or "", r.get("poEndDate") or ""))}
' + f'
환불일
{esc(_ipo_md(r.get("refundDate") or "") or "미정")}
' + f'
상장일
{esc(_ipo_md(lcal) or "미정")}
' + '
' + ) + done_html = ''.join(done_parts) or '
청약완료한 종목이 없습니다.
' + + return ( + '
' + f'
오늘청약 {esc(_ipo_md(today))}
' + + today_html + + '
청약완료 상장일까지 표시
' + + done_html + + '
' + ) + + 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 = '
공모주 정보를 불러올 수 없습니다.
' # 자산보기 sub-panel 안 합산/각계좌별도 panes (양쪽 prerender). 토글 버튼은 페이지 상단(자동 토글 옆) 1곳으로 통일. assets_inner = ( '' + '' '
' '' '' '' + '' '
' ) @@ -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 \'\'+x+\'\';}).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;
-

자산현황

+

자산현황

갱신 {now}
@@ -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 파라미터 없으면 현재 상태를 뒤집는다. diff --git a/agents/stock/workspace/scripts/ipo_alert.py b/agents/stock/workspace/scripts/ipo_alert.py new file mode 100644 index 00000000..8a502c44 --- /dev/null +++ b/agents/stock/workspace/scripts/ipo_alert.py @@ -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()) diff --git a/agents/stock/workspace/scripts/tests/test_ipo_alert.py b/agents/stock/workspace/scripts/tests/test_ipo_alert.py new file mode 100644 index 00000000..b3a29de2 --- /dev/null +++ b/agents/stock/workspace/scripts/tests/test_ipo_alert.py @@ -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()