diff --git a/.gitignore b/.gitignore index c9059797..ccabd325 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ node_modules/ exec-approvals.json update-check.json .claude/ +# 설정 저널 서명키 (2026.9.3 신규) — 32B 바이너리, 권한 600 +config-journal-fingerprint.key # 에이전트별 auth / 런타임 (auth-profiles, codex-home, 세션, inbox 메시지) /agents/*/agent/ @@ -60,6 +62,13 @@ update-check.json /subagents/ /.playwright-mcp/ +# ----- 2026.9.3 업그레이드 산물 (런타임·마이그레이션 잔재) ----- +# doctor 가 남기는 `<원본>.migrated..` 백업. 롤백용이라 파일은 두되 추적은 안 한다 +*.migrated.* +/agent/ +/session-sqlite-migration-runs/ +**/session-sqlite-import-archive/ + # 플러그인 · 플로우 · 태스크 런타임 /flows/ /tasks/ diff --git a/CLAUDE.md b/CLAUDE.md index 13b15e69..7ed7371d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,15 +32,23 @@ Resident entities: - `openclaw.json` — Main configuration: auth profiles, model routing, agent definitions, channel settings, gateway config, plugin registry - `workspace/` — Primary agent workspace containing identity, memory, skills, scripts, and templates -- `agents/` — Per-agent directories (e.g., `stock/` has its own workspace with SOUL/IDENTITY/TOOLS) +- `agents/` — Per-agent directories (e.g., `stock/` has its own workspace with SOUL/IDENTITY/AGENTS). ⚠️ 2026-09-10 OpenClaw 2026.9.3 업그레이드 시 doctor 가 각 워크스페이스의 `TOOLS.md` 를 `AGENTS.md` 로 병합하고 원본을 삭제했다(내용 손실 없음 확인). 도구 목록은 이제 `AGENTS.md` 안에 있다 - **Cron 저장소** — 2026-07-20부터 `state/openclaw.sqlite`의 `cron_jobs` 테이블. `cron/jobs.json`은 없다(`.migrated` 잔재만). 조회·수정은 `openclaw cron {list|show|edit}` (예: `openclaw cron edit --cron "30 7 * * 1-6"` / `--message "<프롬프트>"`). 실행 이력은 `cron_run_logs` - `flows/registry.sqlite` — Flow execution registry - `tasks/runs.sqlite` — Task execution history - `credentials/` — Telegram auth tokens, 키움 REST API 자격증명(`kiwoom.json`, 조회 전용) - `docs/` — 외부 공급사·서비스 공식 문서 보관소(키움 REST API PDF 등). 모든 에이전트 공유. 카탈로그는 `docs/README.md`. 하위 폴더 만들지 않고 직속에 둔다. +- `MAINTENANCE.md` — **코디 월간 점검 체크리스트**. 매월 1일 health-check 가 텔레그램으로 알린다. 주기 잡 생존은 health-check 가 매일 보므로, 여기엔 **바깥이 변해서 우리가 조용히 어긋나는** 것만 담았다(모델 카탈로그·본체 버전·외부 데이터소스 403·문서 드리프트·자격증명 만료) - `identity/` — Device identity and auth - `completions/` — Shell completion scripts (bash/zsh/fish/ps1) +## Runtime (본체·Node) + +- **OpenClaw 2026.9.3** (2026-09-10 `2026.7.1-2` 에서 업그레이드). npm global 설치, `npm root -g` = `/opt/homebrew/lib/node_modules` +- ⚠️ **Node 24 필수** — 2026.9.3 의 `engines` 는 `>=24.16.0 <25 || >=26.1.0` 이라 **node@22 로는 안 뜬다**. keg-only `node@24`(24.20.0) 를 설치해 쓰고, 시스템 전역 `node`(`/opt/homebrew/bin/node`)는 **node@22 로 그대로 뒀다**(다른 도구 영향 최소화). 그래서 openclaw 실행 경로 두 곳에 node@24 절대경로가 박혀 있다 — CLI 래퍼 `~/.local/bin/openclaw`, 게이트웨이 `~/Library/LaunchAgents/ai.openclaw.gateway.plist`(+ `service-env/ai.openclaw.gateway.env` 의 `PATH`). 서비스 쪽 셋은 `openclaw gateway install --force` 가 **현재 실행 중인 node 기준으로 재생성**하므로 손으로 고치지 말고 이 명령을 쓴다 +- ⚠️ **npm 11 은 install script 를 기본 차단**한다 — 그냥 `npm install -g openclaw` 하면 `postinstall-bundled-plugins` 가 안 돌아 번들 플러그인이 빠진다. `--allow-scripts=openclaw,@google/genai,koffi,tree-sitter-bash,protobufjs` 를 붙일 것 +- ⚠️ **FDA 는 node 경로와 무관** — `imsg` 가 자체 responsible process 라 골디 whooing-sync(별도 launchd 잡)는 node 를 바꿔도 chat.db 를 계속 읽는다(2026-09-10 실측). 원래부터 안 되던 건 **게이트웨이 하위**에서 부르는 `imsg` 뿐이고 그건 이 업그레이드로 달라지지 않았다 + ## Version Control (Git) 이 워크스페이스는 git 모노레포로 관리됨. Remote `git.hyowons.net/hyowons/openclaw` (NAS 사설 Gitea), 시크릿·런타임·백업은 `.gitignore`로 제외(`openclaw.json`은 의도적 추적), 토큰 저장돼 `git push` 입력 불필요. @@ -56,7 +64,7 @@ Agents follow this startup sequence defined in `workspace/AGENTS.md`: ## Key Configuration (openclaw.json) -- **Primary model:** `openai-codex/gpt-5.5` with fallbacks to OpenRouter free models 및 gpt-5.5-pro +- **Primary model:** `openai/gpt-5.6-sol` (세 에이전트 공통, codex 하네스=ChatGPT oauth 경로) with fallbacks to `openai/gpt-5.6-terra` → `openai/gpt-5.6-luna` → `openrouter/free`. ⚠️ **체인 전체가 과금 0 이다**(2026-09-10 관리자님 요구 — 유료 모델은 쓰지 않는다). terra·luna 는 sol 과 **같은 ChatGPT oauth(codex) 경로**로 돌고(`openai-chatgpt-responses` 실측), `openrouter/free` 는 OpenRouter 무료 티어다. 앞의 셋이 같은 codex 경로라 **codex 가 통째로 죽으면 셋 다 죽으므로**, 그때를 위해 provider 가 다른 무료 모델을 맨 뒤에 뒀다. ⚠️ **`modelPolicy.allow` 도 이 4종으로 좁혔다** — 폴백 순서만 바꾸면 유료 모델이 allow 에 남아 수동 지정 시 과금되므로, 허용 목록 자체에서 빼는 게 유일한 구조적 차단이다. 유료를 다시 쓰려면 allow 에 먼저 추가해야 한다. ⚠️ `gpt-5.4` 는 **Codex 경로에서 은퇴**해 쓸 수 없다(본체가 `gpt-5.6-terra` 로 대체 안내). 정적 카탈로그의 제외 규칙만 보면 terra·luna 가 안 될 것처럼 보이지만 **실제로는 된다** — 실측이 답이다. ⚠️ 이전 구성(`gpt-5.5` primary, 폴백 haiku·sonnet·`gpt-5.5-pro`)에서 온 이유: 5.5·5.5-pro 는 카탈로그상 **deprecated**(`replacedBy: gpt-5.6-sol`)였고 sol 은 5.5 와 동일 단가라 비용 증가 0 이었다. ⚠️ **`gpt-6-astra` 는 의도적으로 안 쓴다**(2026-09-10 검토·제외): ①단가 $10/$50, 272k 초과 구간 $20/$75 로 sol 의 2~2.5배인데 세 에이전트 업무(뉴스·자막 요약, 분류)엔 그 추론이 불필요 ②openclaw 의 codex **정적 카탈로그에서 명시적으로 제외**돼(`buildOpenAICodexStaticProviderConfig` 의 `modelId === "gpt-6-astra"` 분기) `resolveCodexForwardCompatModel` 의 forward-compat 경로로만 통과하는 미검증 루트 ③provider 가 `openai` 라 **`codex_fallback_monitor` 가 감지하지 못한다**(`FALLBACK_PROVIDERS={"openrouter"}`) — 2배 단가로 조용히 도는 사각지대가 생긴다. 다시 넣자는 제안이 오면 이 세 가지를 먼저 확인할 것 - **Agents:** `main` (default, 클로), `stock` (레이), `budget` (골디) — each with own workspace - **Channels:** Telegram enabled (DM allowlist + group allowlist with requireMention) - **Gateway:** Local mode on port 18789 with Tailscale serve, token auth @@ -72,7 +80,7 @@ Main workspace skills (`workspace/skills/`): - **web-search** — DuckDuckGo 검색 API 기반 웹 검색 (text/markdown/json 출력) Budget agent skills (`agents/budget/workspace/skills/`): -- **whooing-sync** — iMessage 카드결제 알림 → 후잉 가계부 자동 동기화. 매핑은 `state/whooing_account_map.json`, 진행상태는 `state/whooing_synced.json` +- **whooing-sync** — iMessage 카드결제 알림 → 후잉 가계부 자동 동기화. 매핑은 `state/whooing_account_map.json`, 진행상태는 `state/whooing_synced.json`. **증권 이체 매칭**(2026-09-10, `scripts/securities_match.py`): 은행→증권 이체는 은행 SMS 에 **입금자명만** 찍혀 목적지를 알 수 없어 `방효원` 자체이체 룰 → 자본 분개 가드에 막혀 **미반영**됐고, 반대편 증권 입금은 SMS 가 없어 월 1회 reconcile 차액이 전부 `주식평가수익` 으로 분개돼 **이체가 손익으로 둔갑**했다(양쪽 동시 누락). 이제 capital-block 직전에 키움 kt00015 에서 짝(같은 날·같은 금액·반대 방향·`is_principal_flow` 적요)을 찾아 `증권(효원) ← 하나은행(효원)` 이체로 분개한다. ⚠️ **호출은 capital-block 직전뿐이라 평시 키움 콜 0**(이체 있는 사이클만 4콜). ⚠️ **애매하면 매칭 안 한다** — 후보 여럿이면 시각 근접(600초)으로 좁히고 그래도 안 줄면 포기, 한 계좌라도 조회 실패면 전체 포기. ⚠️ **증권 계좌 간 대체는 제외**(`_drop_internal_transfers` — 후잉엔 같은 자산이라 분개 대상이 아닌데 금액이 같으면 오매칭). ⚠️ **kt00015 행에 고유 ID 가 없어** 중복방지 키는 `날짜|계좌|IO|금액|시각` 합성(`state/whooing_securities_matched.json`, POST 성공 후에만 기록). 부수효과로 **reconcile 차액에 순수 평가손익만 남는다** - **monthly-settlement** — 매월 1일 05:00 cron으로 전월 결산 리포트 생성 Stock agent skills (`agents/stock/workspace/skills/`): @@ -96,19 +104,19 @@ Stock agent (`agents/stock/workspace/scripts/`), run with `python3`: - `stock_portfolio_report.py` — Daily portfolio report. 키움 `kt00018`(보유)·`kt00001`(예수금)·`ka10170`(당일매매일지) ground truth. `--by-account`로 계좌 분리 뷰. 당일정산(round-trip·풀매도)은 잔고에 없어 별도 [당일정산] 카드로 표시. ⚠️ **메일 발송 중지 — 2026-08-12 관리자님 결정**(메일 통수 축소). launchd는 `send --no-mail`로 돌아 **스냅샷만** 남긴다(`state/portfolio_daily_snapshot.json` = 자산웹 '당일 평가손익'·`rebase_to_nxt_close` 전일 baseline의 근거라 계속 필요). `--no-mail`은 `send` 경로의 휴장일 self-skip·실패 자가알림을 유지하고 `send_mail` 호출만 건너뛴다(`MAIL_SKIPPED=no_mail_flag`) — **`run`으로 대체 금지**(휴장일 가드가 `send` 분기에만 있어 휴장일 키가 이력에 섞이고 평문 100여 줄이 로그로 쏟아진다). 리포트 본문을 눈으로 볼 땐 수동 `run`(`/tmp/stock_report_preview.html`). ⚠️ 텔레그램 요약 코드(`tg_text`, ~104줄)는 2026-05-14 비활성 이후 **완전한 데드코드**가 됐다 — 되살릴 땐 `main`의 발송 분기만 고치면 되고, 정리할 땐 관리자님 확인 후 - `ipo_calendar_sync.py` — Sync IPO subscription/listing dates to Google Calendar - `fomc_calendar_sync.py` — 미 연준 FOMC 회의 일정 → Google Calendar (`ipo_calendar_sync.py` 구조 복제, 조회+등록 전용). 출처 `federalreserve.gov/monetarypolicy/fomccalendars.htm`(인증 불필요) — `fomc-meeting__month`/`fomc-meeting__date` div 파싱, 날짜 끝 `*`=SEP(경제전망·점도표) 회의. 제목 `[FOMC] YYYY-MM 회의`(+` (SEP)`)는 **2026-03-23 관리자님이 일괄 등록한 기존 표기를 그대로 승계** — 덕분에 기존 항목이 중복 없이 흡수됨. `state_key`=시작월 `YYYY-MM`(FOMC는 한 달에 두 번 안 열려 고유). **이벤트 기간 = 회의 시작일(ET) ~ 발표일(KST)**로 3일짜리 종일 이벤트(`cal_start`/`cal_end`). 성명 발표는 회의 마지막날 `America/New_York` 14:00 = KST 익일 03:00(EDT)·04:00(EST)이라, ET 회의날짜만 넣으면 **정작 한국 장이 결과를 반영하는 날에 캘린더가 비어 브리핑 '오늘 일정'에도 안 뜬다** — 그래서 발표일까지 덮는다(2026-07-29 관리자님 지적으로 수정, 그전엔 2일짜리였음). 제목·description은 ET 회의날짜 기준 유지(뉴스·시황 표기와 일치). ⚠️ `parse_meetings`의 미래 판정 기준도 `start_date`가 아니라 **`cal_end > today`** — ET 회의가 끝났어도 한국 발표일이 남았으면 갱신 대상이다. ⚠️ `--event-color` **미지정** — 기존 colorId 9(블루베리) 보존. 안전장치: 스크래핑 실패로 목록이 비면 cleanup 전체 스킵 / 조회·삭제 범위는 `오늘 이후`로 한정 + **삭제 루프가 `old_start <= today`를 건너뜀**(진행 중이거나 지난 회의는 파싱 대상에서 빠질 뿐인데 stale로 오인돼 지워지는 걸 막음). CLI: `--list`(파싱 결과만, gog 미호출 — 인증 없는 환경에서 파서 검증용) / `--dry-run` / 무인자=실반영. 캐시 `state/fomc_calendar_sync.json` -- `holiday_sync.py` — investing.com에서 한국(KRX) 휴장일 fetch → `state/market_holidays.json`. behive_web.py 자동갱신 토글이 휴장일·평일·시간대로 비활성 판정. CLI: `python3 holiday_sync.py [--show]` +- `holiday_sync.py` — 한국(KRX) 휴장일 → `state/market_holidays.json`. ⚠️ **이 파일은 behive_web 만 쓰는 게 아니라 레이 자동화 대부분의 휴장일 판정 기준**이다 — `trailing_monitor`·`surge_monitor`·`watchlist_monitor`(장중 감시 self-skip), `stock_portfolio_report`·`trade_journal`·`market_indicators_sync`(EOD 적재 self-skip), `behive_web`·`realtime_hub`·`sim`(자동갱신·스캔 활성 판정). 틀려도 잘못된 매매는 없고 자동화가 헛돌며 로그·API 콜만 쓴다. ⚠️ **2026-09-10 부터 investing.com 이 403(봇 차단)이라 사실상 nager 단독 운영**(`source_used` 필드로 구분). KRX 공식(data.krx.co.kr)도 `LOGOUT` 만 반환해 대안이 못 된다. 실측상 nager 단독 결과가 직전 investing 데이터 16일과 **완전히 일치**해 정확도 손실은 확인되지 않았다(이름 표기만 다름: 근로자의 날→노동절 등). ⚠️ nager 는 '공휴일'이라 **거래소 전용 휴장인 연말 폐장일(12/31)이 없다** → 평일일 때만 `연말 휴장`으로 보강한다(investing 데이터에도 없어서 원래 누락돼 있던 날). ⚠️ **미발표 연도 오염 방지 게이트**: KRX 는 다음 해 휴장 캘린더를 11~12월에 발표한다. investing 이 살아 있으면 '그 연도를 잡았다'는 게 발표 신호라 그 연도만 채웠는데, investing 이 죽은 지금은 **달력으로 대신 판정**한다 — 올해는 항상, 내년은 **11월 이후에만**. 그래서 9월인 지금 2027 이 비어 있는 건 정상이고, 12월→1월 갭도 막힌다. CLI: `python3 holiday_sync.py [--show]` - `behive_youtube_digest.py` — 비하이브 YouTube 종목분석 수집·요약·발송 + 수동 워치리스트 추가(`add`)/조회·삭제 - `watchlist_monitor.py` — 워치리스트 종목 장중 15분 시세 감시, buy/target/stop 트리거 발생 시 레이 텔레그램 알림 (LLM 없이 동작). 미보유 종목은 ka10095 batch 1콜 + 단건 ka10001 fallback. 보유 종목은 kt00018 재활용 - `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 정책. **시장 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회 확인. **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 미거래면 열이 통째로 비었다)라 드러나지 않았다. ⚠️ 보유행 '당일 등락'은 여전히 평단 기준이다 — 오늘 산 종목을 어제 종가와 비교하는 건 손익 관점에서 무의미하므로 의도된 동작이고, 미니표(시장 데이터)와 목적이 다르다. ⚠️ 미니표 전환은 **콜 절감이 목적이 아니다**(보유 NX 콜은 어차피 감시·관심 NXT 조회에 얹으면 공짜였다) — 화면 일관성 때문에 선택한 것. **시장 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계좌 통합 + 행별 취소). 매수 시 금액↔수량 양방향 자동(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=하나라도 접수). **매도 주문유형 5종** — 지정가(LIMIT) / 시장가(MARKET) / 스톱지정가(STOP_LIMIT, 하락 시 매도·조건단가 고정) / **트레일링 스톱(TRAILING_STOP, 2026-07-30 추가)** / **예약 트레일링(TRAILING_ARM, 2026-08-25 추가 — 발동가에 닿으면 그때 트레일링 시작. 상세는 아래 「예약 트레일링」)**. 뒤 2개는 매도 전용(매수 선택 시 옵션 disabled + LIMIT로 되돌림, `refreshStopUI`). **트레일링 입력**(단가 입력행은 숨김 — 조건단가·지정가·계단별 주수는 서버 계산): ①**계단 프리셋 드롭다운** — 코드 상수 `TRAIL_PRESETS` **고정 4종, 읽기 전용**. 표시 순서 = **단일 / 보수 / 표준 / 느슨**(기본값은 맨 앞 `단일`, 그 뒤로 트레일 폭이 좁은 것부터). 옵션은 **이름만** — 계단 수치는 바로 아래 편집 표에 그대로 보여 중복이다(2026-07-31 관리자님 지시, 다시 붙이지 말 것). 셸 HTML에 `window.__trailPresets`로 실려 모달 열 때 fetch 0. ⚠️ **프리셋 저장·삭제 기능은 두지 않는다**(2026-07-31 관리자님 지시로 제거 — 고른 뒤 아래 표에서 그 자리에 고쳐 쓰면 되므로 저장소·락·엔드포인트가 불필요했다. `state/trailing_presets.json`도 폐기). 프리셋을 늘리려면 상수를 고친다 ②그 아래 **계단 편집 테이블**(`하락/누적` **헤더 한 줄** + 행마다 `[__]% [__]%` + ✕, 아래 full-width `+ 계단 추가`, 최대 5) ③**최저 매도가**(선택, 비우면 제한 없음). ⚠️ **라벨을 행마다 반복하지 말 것**(2026-07-31 정리) — `.order-inputs` 실폭이 모바일 ~200px라 행마다 '하락'·'누적' 글자와 화살표를 넣으면 입력칸이 38px(실텍스트 26px)까지 눌린다. 헤더로 빼고 화살표를 지워 78px로 회복했고, number 스피너도 `-webkit-appearance:none`으로 없앴다(데스크톱에서 숫자를 가림). ⚠️ 헤더 행도 `.trail-step` 클래스라 **`trailSteps()`는 `:not(.trail-step-head)`로 걸러야 한다** — 안 그러면 입력칸 없는 행을 읽어 TypeError. ⚠️ 계단 0개면 헤더도 그리지 않는다. 단위(`(%)`·`(원)`)는 **라벨에 박는다** — 입력칸 옆에 붙이면 가로로 벌어져 스크롤이 생기고 number input 안쪽 absolute 접미사는 데스크톱 스피너와 겹친다. **누적 비중으로 입력받는 이유** — 관리자님이 그렇게 생각한다("30% 빠지면 전량"). 주문 수량인 추가분 환산은 `normalize_steps`가 한다. `handler.propose_trade(trail_steps, min_sell_price)`가 초기 고점을 `md['current_price']`로 정하고 `compute_step_levels`+`allocate_step_qty`로 계단별 가격·주수를 만든다. **0주가 된 계단은 떨어내되 단계 번호(n)는 유지**해 카드에서 몇 단계가 빠졌는지 보이게 한다. ⚠️ 트레일링에 `budget`(예산 환산) 금지 — 계단 주수는 보유수량을 나눠 만드는데 예산이 끼면 기준이 둘이 된다(`TRAIL_BUDGET`으로 거부). ⚠️ **고점 기준 선택 옵션은 2026-07-30 제거됨**(`peak_basis` / `52주 전고점` / `매수후 고점`) — 과거 고점을 기준으로 잡으면 손절선이 현재가 위로 올라가 등록 즉시 발동한다. **손실 종목은 등록 자체가 불가**(실측 제닉스로보틱스 현재가 4,805 · 매수후 고점 6,890 → 필요 폭 30.3% / 52주 19,940 → 75.9%, 둘 다 상한 30% 초과), **이익 종목은 과거 고점 ≈ 현재가라 결과가 같다**. 즉 차이가 날 때는 못 쓰고 쓸 수 있을 때는 차이가 없어 옵션을 없앴다. **다시 넣지 말 것.** ⚠️ **1단계(가장 얕은 계단) 손절선 ≥ 현재가면 `TRAIL_IMMEDIATE`로 거부** — 고점=현재가 고정이라 이제 최저 매도가가 현재가보다 높은 경우만 남는다. **거부 시점은 입력 시점**(2026-07-30 관리자님 지시로 propose 시점에서 이동) — `trailBlockReason()`이 입력값을 보고 사유 문자열을 내놓고, 걸리면 매도 버튼이 disable + title 에 사유. 계단 순서·범위 위반(`TRAIL_STEPS`)과 "이 수량으론 계단을 못 나눔"(`TRAIL_QTY`)도 같은 함수가 같은 문구로 먼저 막는다. 서버는 최후 방어선으로 유지. ⚠️ **버튼 `disabled`는 `refreshSubmitGate()` 한 곳에서만 결정한다** — 시장 phase 게이트(`updateMarketPhaseDisplay`, `/api/market_state` 주기 폴링)와 트레일 게이트가 같은 버튼을 건드려서, 예전처럼 각자 대입하면 주기 호출이 트레일 게이트를 덮어써 즉시발동 입력이 통과된다. phase 쪽은 `state.phaseCanTrade`/`phaseLabel`만 쓰고 판단을 위임. propose 진행 중에는 `btn.dataset.busy='1'`로 잠가 주기 호출이 버튼을 되살리지 못하게 하고, 응답·에러 후 busy 해제 + 재계산. `renderTrailPreview`는 조기 반환 경로(폭 미입력·시세 로딩)에서도 게이트가 걸려야 하므로 **함수 맨 앞에서** `refreshSubmitGate()`를 부른다. 클라이언트 미리보기(`renderTrailPreview`/`trailCondPrice`/`allocStepQty`/`trailWeights`/`floorTick`)는 서버와 **같은 식**이지만 실제 발주값은 서버 계산분이 승자 — 한쪽만 바꾸면 표시와 발주가 어긋나니 `orders/trailing.py` 수정 시 JS도 함께 고칠 것(**렌더된 페이지에서 함수 소스를 뽑아 node로 실행해 Python과 대조**하는 방식, 현재 418조합: 손절선 360·수량배분 54·비중환산 4). 키움엔 계단마다 스톱지정가로 나가고(`_submit_trailing_steps`가 레그별 `STOP_LIMIT` 제출) 트레일링은 우리 쪽 개념. **카드 1장·PIN 1개로 계단 N건을 승인**한다. **레그 독립 제출** — 얕은 계단부터 접수해 중간에 막혀도 가장 가까운 방어선이 먼저 걸리고, 한 레그가 실패해도 나머지는 시도하며 접수된 것만 `trailing.register_steps`로 등록한다(이미 나간 주문을 되돌리지 않는 이유는 취소도 실패할 수 있어 상태가 더 불분명해져서 — 대신 몇 단이 걸리고 몇 단이 실패했는지 메시지에 그대로 적는다). **등록 실패 시 주문은 이미 접수된 상태**라 메시지에 ⚠️ 경고를 붙이고 ledger `trailing_register_failed` 기록(숨기면 트레일링이 안 도는 걸 모른다). 트레일링·스톱지정가는 단일 계좌 예약이라 **2계좌 동시 매도 분기에서 제외**. 취소는 `[📋 진행중]`에서 해당 미체결 주문 취소 → 다음 감시 사이클이 사라진 예약을 자동 정리. - **예약 트레일링 (TRAILING_ARM)**(2026-08-25) — 발동가를 미리 정해두고 **현재가가 그 가격에 올라와 닿는 순간** 그때의 현재가를 고점으로 트레일링을 등록한다. 아직 오르지 않은 종목에 트레일링을 걸면 손절선이 현재가 기준으로 잡혀 상승 여력을 못 담는데, 그 틈을 메운다. 입력은 트레일링과 동일(계단·최저 매도가)하고 **발동가 하나만 더** 받는다(`data-order-arm-row`). ⚠️ **판정은 상승 도달만**(`현재가 >= 발동가`) — 하락 도달은 스톱지정가와 겹친다. ⚠️ **대기 예약은 키움이 아니라 `state/trailing_stops.json` 의 `arms` 키에만 있다** → 거래소가 지워주지 않으므로 **우리가 스톱주문과 같은 수명(등록일 당일)을 강제**한다(`trailing.is_arm_expired` + 감시 루프의 `_expire_arms`, 2026-08-25 관리자님 지시로 무기한 → 당일 변경). 반대로 **감시 프로세스가 죽으면 발동하지 않는다**(트레일링 스톱과 정반대 성질). ⚠️ **만료 기준은 `guards.after_trading_day`(NXT 애프터 마감 20:00)** — `session_at(now)=='CLOSED'` 로 대신하면 **09:00:00~09:00:30 틈**과 새벽에도 CLOSED 가 나와 아침에 멀쩡한 예약이 만료로 잡힌다. ⚠️ **감시가 08:00~19:59 에만 돌아 20:00 직후엔 파일에 남아 있다** → 실제 정리는 다음 거래일 첫 사이클이고, 그 사이 자산웹은 `만료 — 정리 대기`로 표시한다(숨기면 화면이 거짓말을 한다). ⚠️ **만료 정리는 시세 조회보다 앞에 둔다** — 시세가 실패해도 정리는 돼야 하고, 어제 예약이 오늘 시세로 발동하면 절대 안 된다. 알림은 건수 상관없이 **한 통으로 묶는다**. ⚠️ **PIN 은 등록 시 1회뿐, 발동 시엔 없다** — 관리자님 명시 승인(2026-08-25)으로 `trailing-monitor` 에 **신규 발주 경로**가 생긴 것이고, SOUL.md 자동 트리거 금지의 두 번째 예외다. 그래서 승인 카드·웹 미리보기·확인 팝업 **3곳에 "지금은 주문이 나가지 않습니다 / 발동 시 PIN 재확인 없음"을 명시**해 뒀다(빼지 말 것). ⚠️ **arms 와 reservations 를 한 파일에 둔 이유는 발동이 "arm 제거 + 예약 등록"이라는 하나의 원자적 전이여야 해서다** — `promote_arm` 이 락 하나 안에서 `os.replace` 1회로 끝낸다. 파일을 쪼개면 중간에 죽었을 때 둘 다 존재하거나 둘 다 사라진다. 그래서 `trailing._write(reservations=None, arms=None)` 는 **None 인 쪽을 디스크에서 다시 읽어 보존**한다(기존 6개 호출부가 `_write(reservations)` 그대로여도 arms 가 안 지워지는 이유). ⚠️ **계단은 가격·주수 없이 정의(n/pct/cum/weight)만 저장**하고 라우팅도 저장하지 않는다 — 갭 상승으로 발동가를 훌쩍 넘겨 열릴 수 있고, 같은 날 안에서도 등록 시점 세션(정규장)과 발동 시점 세션(NXT 애프터)이 달라 옛 suffix 로 발주하면 거부된다. 발동 시점에 `plan_arm_fire` + `guards.determine_routing` 으로 새로 계산한다. ⚠️ **크래시 안전은 2단계 마킹** — 발주 전에 `mark_arm_firing` 으로 `firing_at` 을 디스크에 남기고, 레그마다 `record_arm_leg` 로 주문번호를 적는다. **`firing_at` 이 남은 예약은 자동 재발동하지 않는다**(주문이 나갔는지 모르는 상태의 재시도가 곧 이중 매도) — 알림만 보내고 관리자님이 미체결 확인 후 자산웹에서 취소. ⚠️ **대기 수량은 일반 매도 가능수량에서 차감하지 않는다**(대기가 보유분을 묶으면 손이 묶이므로) → 그 사이 직접 팔았으면 발동 시 `min(예약, 매도가능)`으로 **축소 재배분**하고 알림에 명시, 0주면 예약 삭제+알림. ⚠️ **사이드카(`/orders_off`)는 마킹 *전에* 확인**한다 — 마킹 후 전 레그가 `SidecarBlocked` 로 죽으면 예약이 발동 처리 중단 상태로 갇힌다. 조회는 `arms` 심볼을 기존 ka10095 배치에 합쳐 **API 콜 추가 0**, 미도달 평시엔 키움 콜 0(발동한 계좌만 kt00018 1콜). arm 평가는 손절선 상향이 **끝난 뒤** 별도 `try/except` 로 도는데, 순서가 설계다 — 실주문을 지키는 상향을 arm 쪽 예외가 선점하면 안 된다. 대기 예약은 ka10075 에 안 잡혀 **`[📋 진행중]` 팝업이 유일한 창구**(`_arm_rows()` 합성 행, `kind='arm'`·`ord_no` 빈 문자열로 미체결 취소가 삼키지 못하게 분리, 취소는 `POST /api/orders/arm_cancel` → `handler.cancel_trailing_arm`, 텔레그램 없음). ⚠️ **종목 변경 시 발동가·최저 매도가를 비우고 주문유형을 LIMIT 으로 되돌린다** — 절대가격이라 이전 종목 값이 남으면 엉뚱한 가격으로 예약이 나간다(계단은 %라 유지). ⚠️ **발동가는 가격제한폭 검증을 받지 않는다** — 실제 발주는 발동 시점 현재가로 계산되므로 그때 검증된다. 당일 유효가 된 뒤로 상한가 초과는 '오늘 도달 불가 = 그냥 만료'를 뜻하지만 **막지 않고 경고만** 한다(2026-08-25 관리자님 결정). 경고 위치 3곳 — 승인 카드(`card.format_card`)·웹 미리보기(`renderTrailPreview`)·확인 팝업(`openOrderConfirm`). `guards` 에서 거부로 바꾸지 말 것. ⚠️ 트레일링 예약이 미체결 매도로 잡혀 있으면 `/api/order/check`의 `_pending_sell_qty` 차감 덕에 **일반 매도 max_qty가 자동으로 줄어든다**(이중 매도 구조적 차단). + **예약 트레일링 (TRAILING_ARM)**(2026-08-25) — 발동가를 미리 정해두고 **현재가가 그 가격에 올라와 닿는 순간** 그때의 현재가를 고점으로 트레일링을 등록한다. 아직 오르지 않은 종목에 트레일링을 걸면 손절선이 현재가 기준으로 잡혀 상승 여력을 못 담는데, 그 틈을 메운다. 입력은 트레일링과 동일(계단·최저 매도가)하고 **발동가 하나만 더** 받는다(`data-order-arm-row`). ⚠️ **판정은 상승 도달만**(`현재가 >= 발동가`) — 하락 도달은 스톱지정가와 겹친다. ⚠️ **대기 예약은 키움이 아니라 `state/trailing_stops.json` 의 `arms` 키에만 있다** → 거래소가 지워주지 않으므로 **우리가 스톱주문과 같은 수명(등록일 당일)을 강제**한다(`trailing.is_arm_expired` + 감시 루프의 `_expire_arms`, 2026-08-25 관리자님 지시로 무기한 → 당일 변경). 반대로 **감시 프로세스가 죽으면 발동하지 않는다**(트레일링 스톱과 정반대 성질). ⚠️ **만료 기준은 `guards.after_trading_day`(NXT 애프터 마감 20:00)** — `session_at(now)=='CLOSED'` 로 대신하면 **09:00:00~09:00:30 틈**과 새벽에도 CLOSED 가 나와 아침에 멀쩡한 예약이 만료로 잡힌다. ⚠️ **감시가 08:00~19:59 에만 돌아 20:00 직후엔 파일에 남아 있다** → 실제 정리는 다음 거래일 첫 사이클이고, 그 사이 자산웹은 `만료 — 정리 대기`로 표시한다(숨기면 화면이 거짓말을 한다). ⚠️ **만료 정리는 시세 조회보다 앞에 둔다** — 시세가 실패해도 정리는 돼야 하고, 어제 예약이 오늘 시세로 발동하면 절대 안 된다. 알림은 건수 상관없이 **한 통으로 묶는다**. ⚠️ **PIN 은 등록 시 1회뿐, 발동 시엔 없다** — 관리자님 명시 승인(2026-08-25)으로 `trailing-monitor` 에 **신규 발주 경로**가 생긴 것이고, SOUL.md 자동 트리거 금지의 두 번째 예외다. 그래서 승인 카드·웹 미리보기·확인 팝업 **3곳에 "지금은 주문이 나가지 않습니다 / 발동 시 PIN 재확인 없음"을 명시**해 뒀다(빼지 말 것). ⚠️ **arms 와 reservations 를 한 파일에 둔 이유는 발동이 "arm 제거 + 예약 등록"이라는 하나의 원자적 전이여야 해서다** — `promote_arm` 이 락 하나 안에서 `os.replace` 1회로 끝낸다. 파일을 쪼개면 중간에 죽었을 때 둘 다 존재하거나 둘 다 사라진다. 그래서 `trailing._write(reservations=None, arms=None)` 는 **None 인 쪽을 디스크에서 다시 읽어 보존**한다(기존 6개 호출부가 `_write(reservations)` 그대로여도 arms 가 안 지워지는 이유). ⚠️ **계단은 가격·주수 없이 정의(n/pct/cum/weight)만 저장**하고 라우팅도 저장하지 않는다 — 갭 상승으로 발동가를 훌쩍 넘겨 열릴 수 있고, 같은 날 안에서도 등록 시점 세션(정규장)과 발동 시점 세션(NXT 애프터)이 달라 옛 suffix 로 발주하면 거부된다. 발동 시점에 `plan_arm_fire` + `guards.determine_routing` 으로 새로 계산한다. ⚠️ **크래시 안전은 2단계 마킹** — 발주 전에 `mark_arm_firing` 으로 `firing_at` 을 디스크에 남기고, 레그마다 `record_arm_leg` 로 주문번호를 적는다. **`firing_at` 이 남은 예약은 자동 재발동하지 않는다**(주문이 나갔는지 모르는 상태의 재시도가 곧 이중 매도) — 알림만 보내고 관리자님이 미체결 확인 후 자산웹에서 취소. ⚠️ **대기 수량은 일반 매도 가능수량에서 차감하지 않는다**(대기가 보유분을 묶으면 손이 묶이므로) → 그 사이 직접 팔았으면 발동 시 `min(예약, 매도가능)`으로 **축소 재배분**하고 알림에 명시, 0주면 예약 삭제+알림. ⚠️ **사이드카(`/orders_off`)는 마킹 *전에* 확인**한다 — 마킹 후 전 레그가 `SidecarBlocked` 로 죽으면 예약이 발동 처리 중단 상태로 갇힌다. 조회는 `arms` 심볼을 기존 ka10095 배치에 합쳐 **API 콜 추가 0**, 미도달 평시엔 키움 콜 0(발동한 계좌만 kt00018 1콜). arm 평가는 손절선 상향이 **끝난 뒤** 별도 `try/except` 로 도는데, 순서가 설계다 — 실주문을 지키는 상향을 arm 쪽 예외가 선점하면 안 된다. 대기 예약은 ka10075 에 안 잡혀 **미체결 기반 표시(`pending_*`)에서 통째로 빠진다** — 2026-09-10 이전엔 그래서 종목 행이 완전히 조용했고 `[📋 진행중]` 팝업이 유일한 창구였다. 지금은 `_arms_by_code()`(상태파일 읽기, 키움 콜 0)가 payload 단계에서 코드별로 주입해 **보유·관심·감시 행에 ⏳ 배지(`badge pending-arm`) + 자세히보기 '예약 트레일링' 상세**(발동가·발동가까지 %·계단·최저 매도가·계좌/등록시각/예약ID)를 그린다. ⚠️ **owner 필터는 `derive_owner(account)`** — 같은 종목을 본인·가희가 함께 보유하면 필터 없이는 남의 탭에 예약이 뜬다(실측 제닉스로보틱스가 양쪽 보유). ⚠️ 주입 대상은 **consolidated·phantom·워치리스트 카드**뿐이라 계좌별 pane(raw rows)엔 안 뜬다 — 기존 `pending_*` 주입 범위와 같다. 상태 라벨은 `firing_at` > `expired` 순으로 갈린다(발동 중단이 만료보다 중요). **취소는 여전히 `[📋 진행중]` 팝업이 유일한 창구**(`_arm_rows()` 합성 행, `kind='arm'`·`ord_no` 빈 문자열로 미체결 취소가 삼키지 못하게 분리, 취소는 `POST /api/orders/arm_cancel` → `handler.cancel_trailing_arm`, 텔레그램 없음). ⚠️ **종목 변경 시 발동가·최저 매도가를 비우고 주문유형을 LIMIT 으로 되돌린다** — 절대가격이라 이전 종목 값이 남으면 엉뚱한 가격으로 예약이 나간다(계단은 %라 유지). ⚠️ **발동가는 가격제한폭 검증을 받지 않는다** — 실제 발주는 발동 시점 현재가로 계산되므로 그때 검증된다. 당일 유효가 된 뒤로 상한가 초과는 '오늘 도달 불가 = 그냥 만료'를 뜻하지만 **막지 않고 경고만** 한다(2026-08-25 관리자님 결정). 경고 위치 3곳 — 승인 카드(`card.format_card`)·웹 미리보기(`renderTrailPreview`)·확인 팝업(`openOrderConfirm`). `guards` 에서 거부로 바꾸지 말 것. ⚠️ 트레일링 예약이 미체결 매도로 잡혀 있으면 `/api/order/check`의 `_pending_sell_qty` 차감 덕에 **일반 매도 max_qty가 자동으로 줄어든다**(이중 매도 구조적 차단). **텔레그램 발송 정책 (웹 매매)** — 거부·검증 에러는 토스트만, **매매등록(submit_with_pin 성공)·매매체결(fill_watcher)만** 텔레그램. PIN 메시지는 **iMessage** (Apple 도메인 바인딩 `@stock.hyowons.net #PIN`, iOS Safari OTP 자동입력). `handler.send_imessage_pin` (fire-and-forget Popen, AppleEvent timeout -1712 떠도 메시지는 큐로). credential `credentials/admin_imessage.json` `{"handle": "01012345678"}`. 자기 자신 iMessage self-send 가능 (mac → 본인 iCloud handle). **규칙(나→나 iMessage)**: 관리자 본인 handle로 보내는 iMessage는 항상 `--service imessage`(파란) 강제 — SMS 셀프발송 금지. 이유: iMessage 셀프발송은 Note-to-Self라 보낸 버블만 뜨고 OTP 도메인 자동입력이 정상 동작하지만, SMS 셀프발송은 통신사 loopback으로 초록·수신버블이 생기고 자동입력이 깨질 수 있음. **타인 대상도 `--service imessage`** — 가희 리마인더(`gahee_reminder._send_imessage`)가 `sms` 강제였다가 2026-08-03 전환. SMS 강제는 그 수신자에게 3전 3패였고(전부 `error=4` 미발송, 살아남은 건은 Messages가 임의로 RCS로 바꿔준 운), 7/25 미발송이 성공으로 기록돼 7월 잔액이 통째로 누락됐다. ⚠️ **`imsg send`의 rc=0은 발송 성공이 아니다** — Messages에 넘겼다는 뜻일 뿐이고 실제 실패는 비동기로 `chat.db`의 `error`에 찍힌다. rc만 보는 코드는 미발송을 성공으로 오인한다. ⚠️ **자동화 → 메시지(AppleEvents) 권한이 `imsg`·iTerm2에만 허용, Claude Code에는 거부**라 코디 셸에서 `imsg send`를 부르면 20초 타임아웃. 발송은 launchd 경유(임시 oneshot plist)로만 가능. @@ -163,18 +171,20 @@ OpenClaw 자동화는 두 갈래로 동작한다 (모두 Asia/Seoul): - **stock.briefing-fallback-2030** — 평일 20:30 — 오늘 스냅샷 없으면 `send --no-mail` 재실행 (idempotent). 최근 2주 실측 한 번도 실제 작업 안 함(20:10이 매일 성공) - **stock.briefing-fallback-2100** — 평일 21:00 — **무조건 fresh fetch로 스냅샷 갱신** (`briefing_fallback.py force` → 스냅샷 있으면 `stock_portfolio_report.py run` 메일·텔레그램 X, 없으면 `send --no-mail` 폴백 + 실패 시 알림). 20:10 데이터 부정확 케이스 보완용 - **stock.watchlist-monitor** — 평일 10:00 / 12:00 / 14:00 — 워치리스트 buy/target/stop 알림 (2026-05-12: 15분 간격 → 3회로 축소) -- **stock.trailing-monitor** — 평일 08:00–19:59 **매 1분** (`StartCalendarInterval` 720엔트리, 스크립트 self-skip — 2026-08-25 plist 실측 정정. 그전엔 "09:00–15:30 391엔트리"로 적혀 있었다) — `trailing_monitor.py check`: **트레일링 스톱 감시**. ⚠️ **매매 API를 자동 호출하는 유일한 트리거** — 매매 자동 트리거 금지 규칙의 예외로 2026-07-30 관리자님 명시 승인. 호출 대상은 ①`kiwoom_order.modify_order`(조건단가 상향 정정) ②**예약 트레일링 발동 시 `submit(STOP_LIMIT)` 신규 발주**(2026-08-25 관리자님 승인) 2종. ⚠️ 그전 근거였던 "신규 발주 경로가 없음"은 더 이상 사실이 아니다 — 바뀐 근거는 **계좌·종목·수량·계단·발동가가 PIN 승인 시점에 확정되고 감시는 발동가 도달 판정만 한다**(수량 증가·방향 변경 경로 없음, 매도가능 부족 시 축소만)(PIN 승인 시 계좌·종목·계단별 수량·하락률 확정, 감시는 조건단가 **상향만**. 계단이 체결돼도 재배치=취소+신규발주는 하지 않는다). 예약 0건이면 즉시 종료(API 콜 0). ⚠️ 계단식이라 **정정 콜이 계단 수에 비례**(예약 3건×5계단이면 분당 최대 15콜). 로그 `logs/stock-trailing-monitor.{log,err.log}`. ⚠️ **cadence 이력 2분→30초→1분**(전부 2026-07-30) — launchd 최소 단위가 1분이라 30초는 프로세스 내부 `--repeat 2 --gap 30`으로 구현했고 CLI 옵션은 살아있음(다시 쓸 땐 plist 인자만 추가). `StartInterval=30`은 GUI idle 시 발화 보류라 사용 금지 +- **stock.trailing-monitor** — 평일 08:00–19:59 **매 1분** (`StartCalendarInterval` 720엔트리, 스크립트 self-skip — 2026-08-25 plist 실측 정정. 그전엔 "09:00–15:30 391엔트리"로 적혀 있었다) — `trailing_monitor.py check`: **트레일링 스톱 감시**. ⚠️ **매매 API를 자동 호출하는 유일한 트리거** — 매매 자동 트리거 금지 규칙의 예외로 2026-07-30 관리자님 명시 승인. 호출 대상은 ①`kiwoom_order.modify_order`(조건단가 상향 정정) ②**예약 트레일링 발동 시 `submit(STOP_LIMIT)` 신규 발주**(2026-08-25 관리자님 승인) 2종. ⚠️ 그전 근거였던 "신규 발주 경로가 없음"은 더 이상 사실이 아니다 — 바뀐 근거는 **계좌·종목·수량·계단·발동가가 PIN 승인 시점에 확정되고 감시는 발동가 도달 판정만 한다**(수량 증가·방향 변경 경로 없음, 매도가능 부족 시 축소만)(PIN 승인 시 계좌·종목·계단별 수량·하락률 확정, 감시는 조건단가 **상향만**. 계단이 체결돼도 재배치=취소+신규발주는 하지 않는다). 예약 0건이면 즉시 종료(API 콜 0). ⚠️ 계단식이라 **정정 콜이 계단 수에 비례**(예약 3건×5계단이면 분당 최대 15콜). 로그 `logs/stock-trailing-monitor.{log,err.log}` — ⚠️ **이벤트가 있을 때만 찍는다**(예약 0건이면 한 줄도 안 남아 2026-08-28~09-10 13일간 로그 무갱신이 정상인지 장애인지 구분 불가였다). 그래서 사이클마다 `state/trailing_monitor_heartbeat` 의 mtime 만 갱신하는 생존 신호를 두고 `health_check.py` 가 그 파일을 읽는다(로그로 찍으면 분당 1줄 × 720회/일이 실제 이벤트를 덮는다). 예약 트레일링은 감시가 죽으면 아예 발동하지 않으므로 이 잡은 생존 확인이 특히 중요하다. ⚠️ **cadence 이력 2분→30초→1분**(전부 2026-07-30) — launchd 최소 단위가 1분이라 30초는 프로세스 내부 `--repeat 2 --gap 30`으로 구현했고 CLI 옵션은 살아있음(다시 쓸 땐 plist 인자만 추가). `StartInterval=30`은 GUI idle 시 발화 보류라 사용 금지 - **stock.surge-monitor** — 평일 09:00–15:35 **매 1분** (`StartCalendarInterval` 396엔트리, 스크립트 self-skip) — `surge_monitor.py check`: **급등락 알림**(VI 발동 + ATR 배수 돌파 → 레이 텔레그램). 조회·알림 전용으로 주문 API 호출 경로가 없어 매매 자동 트리거 금지 규칙에 저촉되지 않음. 토글 0건이면 즉시 종료(API 콜 0). 평시 사이클 비용은 behive_web localhost 1콜 + 키움 0콜. 로그 `logs/stock-surge-monitor.{log,err.log}`. ⚠️ `StartInterval` 금지(GUI idle 시 발화 보류) - **stock.ipo-calendar-sync** — **월·수·금 17:00** (plist 실측 `Weekday 1,3,5`; 문서에 오래 '매주 금요일'로 잘못 적혀 있었다 — 2026-08-12 수정) — IPO 청약·상장 일정 캘린더 등록 - **stock.fomc-calendar-sync** — 매월 1일 09:10 — `fomc_calendar_sync.py`: 연준 FOMC 회의 일정 캘린더 등록·갱신. 연준이 1년 이상 앞서 공표하고 변경이 거의 없어 월 1회로 충분(폴백 트리거 없음). LLM 미경유. 로그 `logs/fomc-calendar-sync.{log,err.log}` -- **stock.holiday-sync** — 매주 일요일 03:00 — investing.com KRX 휴장일 → `state/market_holidays.json` (behive_web 자동갱신 토글이 참조) +- **stock.holiday-sync** — 매주 일요일 03:00 — KRX 휴장일 → `state/market_holidays.json`. 레이 자동화 전반의 휴장일 판정 기준(상세는 위 `holiday_sync.py`). ⚠️ investing 403 차단 이후 nager 단독으로 도는 중이라 **stderr 에 `investing ... 403` 두 줄이 매주 남는 것이 정상**이다(양쪽 다 실패해야 알림). 2026-07-12~09-10 사이엔 investing 0건이면 저장 자체를 건너뛰는 가드 때문에 **2개월간 조용히 stale 이었다** - **stock.send-balance** — 매월 1일 04:30 — 본인 잔액 → 골디 inbox (`securities_balance`) -- **stock.trade-journal** — 평일 21:00 — EOD 데이터 누적 묶음. ProgramArguments는 `/bin/sh -c` wrapper로 세 명령 sequential 실행: ①`trade_journal.py collect` (ka10170 4계좌 → `state/trade_journal.jsonl`) ②`market_indicators_sync.py collect` (네이버 m.stock KOSPI/KOSDAQ ADR·투자자별 매매 → `state/market_indicators_history.jsonl`) ③`cd workspace && python3 -m sim.backfill_flow --pages 1 --force` (수급 DB `flow_history.sqlite` 일일 증분 — 2026-06-10 추가, 1회성 백필 후 stale해져 백테스트 수급게이트가 중립으로 비활성되던 문제 해소. INSERT OR REPLACE idempotent, 행수기준 skip이라 `--force` 필수) ④`python3 -m sim universe all` (2026-06-15 추가 — sim 관찰목록 자동편입+자동제외. 키움 순위정보 ka90009 외인·기관 순매수 + ka10023 거래량급증 상위에서 ETF·하락·급증률 이상치 제외하고 각 15개·총 120 상한 편입(origin='auto'), origin=auto·미보유·10거래일 무신호는 자동 제외. 보유·watch·interest·manual은 불가침. 신호추적은 scan_all이 `universe.mark_signals`로 `state/sim/auto_seen.json`에 기록). 하나 실패해도 나머지 시도. 로그는 `logs/stock-trade-journal.{log,err.log}` 한 곳에 합쳐짐. +- **stock.trade-journal** — 평일 21:00 — EOD 데이터 누적 묶음. ProgramArguments는 `/bin/sh -c` wrapper로 세 명령 sequential 실행: ①`trade_journal.py collect` (ka10170 4계좌 → `state/trade_journal.jsonl`) ②`market_indicators_sync.py collect` (네이버 m.stock KOSPI/KOSDAQ ADR·투자자별 매매 → `state/market_indicators_history.jsonl`) ③`cd workspace && python3 -m sim.backfill_flow --pages 1 --force` (수급 DB `flow_history.sqlite` 일일 증분 — 2026-06-10 추가, 1회성 백필 후 stale해져 백테스트 수급게이트가 중립으로 비활성되던 문제 해소. INSERT OR REPLACE idempotent, 행수기준 skip이라 `--force` 필수) ⑤`kiwoom_client.py refresh-codes --if-stale 7` (2026-09-10 추가 — 종목코드 캐시 주 1회 갱신. `--if-stale N` 은 파일 mtime 이 N일 미만이면 건너뛰므로 매일 발화해도 실제 조회는 주 1회. ⚠️ 이 캐시의 `nxt_enable` 이 자산웹의 NXT 거래 종목 판정 근거다 — lazy 갱신은 '모르는 종목명 검색' 때만 돌아 3주간 방치돼 있었고, 갱신하니 4300종목 중 3건이 실제로 바뀌었다) ④`python3 -m sim universe all` (2026-06-15 추가 — sim 관찰목록 자동편입+자동제외. 키움 순위정보 ka90009 외인·기관 순매수 + ka10023 거래량급증 상위에서 ETF·하락·급증률 이상치 제외하고 각 15개·총 120 상한 편입(origin='auto'), origin=auto·미보유·10거래일 무신호는 자동 제외. 보유·watch·interest·manual은 불가침. 신호추적은 scan_all이 `universe.mark_signals`로 `state/sim/auto_seen.json`에 기록). 하나 실패해도 나머지 시도. 로그는 `logs/stock-trade-journal.{log,err.log}` 한 곳에 합쳐짐. - **stock.sim-scan** — 평일 09:00–15:30 **매 2분** (`StartCalendarInterval` 196엔트리, 엔진이 장외/휴장 self-skip) — **자동매매 시뮬(페이퍼)** 1회 스캔. (2026-06-09 15분→5분, 2026-06-17 5분→2분 단축 — 스캔 1회 실측 ~3.6초라 2분 cadence에 오버랩·rate limit 여유. 1분이 캘린더 트리거 한계지만 일봉 기반 신호라 2분으로 충분 판단) ⚠️ 2026-06-09 `StartInterval` 900s → `StartCalendarInterval` 전환: GUI LaunchAgent의 StartInterval은 세션 idle/디스플레이 sleep 시 timer coalescing으로 발화가 보류(`pended`)돼 장중에 안 도는 문제 발견 (calendar 잡인 git-autopush는 새벽 02:00에도 정상 발화하는 게 비교 증거). 월시간 기준이라 idle 지연 없음. `python3 -m sim scan` (cwd `agents/stock/workspace`). 가상자본 1억, 규칙 엔진(LLM 미경유). universe=누적 관찰목록(워치+관심+보유 자동편입·수동삭제), 방향=애널 약한게이트+가점, 타이밍=수급·기술. 결과 `state/sim/`. 실주문 `orders/` 불가침. 상세는 아래 sim 모듈 섹션·레이 MEMORY.md - **stock.sim-web** — 상시 (`KeepAlive`) — 시뮬 대시보드 **별도 서버 포트 18792** (behive-web 18790과 분리). `python3 -m sim.sim_web serve`. sim state 읽기전용 렌더. `sim.hyowons.net` → mac:18792 (Synology reverse proxy, **수동 등록 대기**). ⚠️ 18791은 node 점유라 18792 사용 - **gmail-label-classify** (main/클로) — 매일 01:00 — `workspace/scripts/gmail_label_classify.py`: self-sent 브리핑·종목분석·주식 리포트 메일을 Gmail 라벨로 자동 분류 + 24h 지난 테스트메일 휴지통 이동. LLM 미경유(`gog` CLI 직접 호출). 로그 `logs/gmail-label-classify.{log,err.log}` + 상태 `state/gmail_label_classify.log`. ⚠️ 2026-06-26 OpenClaw cron(main 01:00)에서 이관 — 본문 생성 없는 단순 스크립트라 모델 세션 불필요(TASKS.md §3-3 원칙) - **git-autopush** — 매일 02:00 — `scripts/git_autopush.sh`: 변경 있으면 `git add -A` + 자동 커밋 + `git push origin main`. 변경 없으면 skip(빈 커밋 X). 인증은 HTTPS + `credential.helper=store`(`~/.git-credentials`), GUI 키체인 불필요. 로그 `logs/git-autopush.{log,err.log}`. 워크스페이스 버전관리 백업용 (시크릿·sqlite는 .gitignore 제외라 별도 백업 필요) -- **codex-fallback-monitor** — 평일·주말 매 5분(`StartCalendarInterval` Minute 0/5/…/55) — `workspace/scripts/codex_fallback_monitor.py`: codex 주 모델(openai/gpt-5.5)이 죽어 게이트웨이가 조용히 **유료 OpenRouter로 폴백**하면 토큰비가 새는 걸 감지→클로(main) 텔레그램 즉시 알림. 신호원=`agents/*/sessions/*.trajectory.jsonl`의 `model.completed` 이벤트(승자 모델). 승자 `provider==openrouter`면 폴백으로 판정(정상=`openai/gpt-5.5` codex 하네스). 무비용·온디스크·LLM 미경유. dedupe=`state/codex_fallback_monitor.json` 워터마크(`last_ts`)+30분 쿨다운(장기 다운 스팸 방지). 첫 실행은 과거 무시하고 워터마크만 세팅. 발송은 텔레그램 Bot API 직접 HTTP POST(`openclaw.json` `channels.telegram.accounts.default` 토큰·allowFrom, 레이 `send_telegram` 스크립트와 동일 패턴, urllib stdlib). 로그 `logs/codex-fallback-monitor.{log,err.log}`. ⚠️ codex 에러 자체는 로그·audit(metadata-only)에 안 남고 폴백 시 OpenRouter는 codex 하네스 로그를 안 남겨, trajectory `model.completed`가 유일하게 신뢰 가능한 승자 신호 +- **codex-fallback-monitor** — 평일·주말 매 5분(`StartCalendarInterval` Minute 0/5/…/55) — `workspace/scripts/codex_fallback_monitor.py`: codex 주 모델(openai/gpt-5.6-sol)이 죽어 게이트웨이가 조용히 **유료 OpenRouter로 폴백**하면 토큰비가 새는 걸 감지→클로(main) 텔레그램 즉시 알림. 신호원=`agents/*/agent/openclaw-agent.sqlite` 의 **`trajectory_runtime_events`** 테이블에 쌓이는 `model.completed` 이벤트(승자 모델). ⚠️ **2026-09-10 2026.9.3 업그레이드로 파일(`agents/*/sessions/*.trajectory.jsonl`)에서 이 테이블로 이관**됐다 — 옛 파일 glob 은 매치 0건이라 예외 없이 '폴백 없음'만 반환하며 **조용히 무력화**된다(워터마크가 멈춘 것이 유일한 증상). 게이트웨이가 쓰는 중이므로 `mode=ro` + busy timeout 으로 읽는다. ⚠️ **판정 기준은 provider 가 아니라 `primary` 와 다른 모델인가**(2026-09-10 변경) — 폴백 체인이 전부 무과금이 되면서 `sol→terra` 같은 **구독 내 폴백을 provider 만 보면 통째로 놓친다**. 과금 0 이어도 '주 모델이 죽었다'는 사실은 알아야 한다. `_primary_models()` 가 `openclaw.json` 에서 에이전트별 primary 를 읽고, 못 읽을 때만 옛 규칙(openrouter=폴백)으로 후퇴한다. 알림 문구는 `_is_billed` 로 갈린다 — 유료면 🚨 즉시 점검, 무과금이면 ⚠️ 과금 없음. ⚠️ **`_model_key` 정규화를 반드시 거친다** — openrouter 는 modelId 에 provider 가 이미 붙어 와(`openrouter/free`) 그냥 이어붙이면 `openrouter/openrouter/free` 가 되고 **무료가 유료로 오판된다**(2026-09-10 실측으로 잡은 버그). 무비용·온디스크·LLM 미경유. dedupe=`state/codex_fallback_monitor.json` 워터마크(`last_ts`)+30분 쿨다운(장기 다운 스팸 방지). 첫 실행은 과거 무시하고 워터마크만 세팅. 발송은 텔레그램 Bot API 직접 HTTP POST(`openclaw.json` `channels.telegram.accounts.default` 토큰·allowFrom, 레이 `send_telegram` 스크립트와 동일 패턴, urllib stdlib). 로그 `logs/codex-fallback-monitor.{log,err.log}`. ⚠️ codex 에러 자체는 로그·audit(metadata-only)에 안 남고 폴백 시 OpenRouter는 codex 하네스 로그를 안 남겨, trajectory `model.completed`가 유일하게 신뢰 가능한 승자 신호 +- **log-rotate** — 매주 일요일 04:00 — `scripts/log_rotate.py`: `logs/` 크기 상한 유지. 30일 초과 로그는 trash, 20MB 초과 **활성** 로그는 최근 3000줄만 남기고 **in-place** 축소. ⚠️ **rename 방식 금지** — launchd 데몬은 이미 연 inode 에 계속 쓰므로 디스크는 안 줄고 새 파일만 비어 있게 된다. 같은 fd 를 유지한 채 앞으로 당겨쓰고 truncate 한다(데몬이 O_APPEND 로 열기 때문에 안전 — 2026-09-10 behive-web 112MB 를 축소한 뒤 되돌아가지 않음을 실측). 도입 시점 실측 **1033MB → 30MB**(대부분 종료된 claude-session 로그, 단일 파일 422MB). ⚠️ 게이트웨이 로그는 대상 아님 — 2026.9.3 부터 `~/Library/Logs/openclaw/` 로 옮겨가 본체가 자체 관리한다(`logs/gateway.*` 19MB 는 그때 남은 고아라 STALE 로 정리됨). 로그 `logs/log-rotate.{log,err.log}` +- **health-check** — **매일 09:00** — `scripts/health_check.py`: **조용히 죽은 자동화 감지**. 감시 대상은 **`TASKS.md` §4 표를 파싱**해서 정한다 — 표가 곧 설정이라, 트리거를 바꾸고 표를 안 고치면 알림이 떠서 문서 드리프트가 스스로 드러난다. 판정은 exit code 가 아니라 **결과물의 mtime**(2026-09-10 점검에서 하드 실패는 0건인데 `holiday_sync` 2개월 stale·`codex_fallback_monitor` 무력화처럼 **exit 0 인데 결과가 옛것**인 실패가 실제 위험으로 드러났다). ⚠️ **증거는 '그 잡이 돌면 반드시 갱신되는' 파일이어야 한다** — `surge_alerts.json`·`trailing_stops.json`·`whooing_synced.json` 처럼 일이 있을 때만 쓰이는 파일은 조용한 날 stale 로 보여 오탐이 된다. ⚠️ KeepAlive 데몬은 `daemon: