diff --git a/CLAUDE.md b/CLAUDE.md index fda673ca..9df18bc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,11 +102,13 @@ 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 즉시가 아니라 몇 시간 뒤. **자동 재등록은 하지 않는다**(관리자님 결정) — 알림만 보내고 재등록은 자산웹에서 수동. 정정 수량은 저장 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 정책. **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 정책. **시장 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). **거래 모달 시스템 (`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=하나라도 접수). - **매도 주문유형 4종** — 지정가(LIMIT) / 시장가(MARKET) / 스톱지정가(STOP_LIMIT, 하락 시 매도·조건단가 고정) / **트레일링 스톱(TRAILING_STOP, 2026-07-30 추가)**. 뒤 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계좌 동시 매도 분기에서 제외**. 취소는 `[📋 진행중]`에서 해당 미체결 주문 취소 → 다음 감시 사이클이 사라진 예약을 자동 정리. ⚠️ 트레일링 예약이 미체결 매도로 잡혀 있으면 `/api/order/check`의 `_pending_sell_qty` 차감 덕에 **일반 매도 max_qty가 자동으로 줄어든다**(이중 매도 구조적 차단). + **매도 주문유형 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가 자동으로 줄어든다**(이중 매도 구조적 차단). **텔레그램 발송 정책 (웹 매매)** — 거부·검증 에러는 토스트만, **매매등록(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)로만 가능. @@ -161,7 +163,7 @@ 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** — 평일 09:00–15:30 **매 1분** (`StartCalendarInterval` 391엔트리, 스크립트 self-skip) — `trailing_monitor.py check`: **트레일링 스톱 감시**. ⚠️ **매매 API를 자동 호출하는 유일한 트리거** — 매매 자동 트리거 금지 규칙의 예외로 2026-07-30 관리자님 명시 승인. 호출하는 건 `kiwoom_order.modify_order` 하나뿐이고 신규 발주·수량·방향 변경 경로가 없음(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}`. ⚠️ **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}` diff --git a/TASKS.md b/TASKS.md index ce2ad26b..08baa1c5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -18,7 +18,7 @@ | **증권잔액 → 골디 inbox** | `python3 ~/.openclaw/agents/stock/workspace/scripts/send_balance_to_budget.py` | launchd `ai.openclaw.stock.send-balance` (매월 1일 04:30) | | **골디 inbox 처리** (단독) | `python3 ~/.openclaw/agents/budget/workspace/skills/monthly-settlement/scripts/inbox_handler.py [--dry-run]` | 트리거 없음 — 월간결산 cron 진입부에서 자동 호출. 수동 점검·재처리용 | | **워치리스트 모니터링** | `python3 ~/.openclaw/agents/stock/workspace/scripts/watchlist_monitor.py check` | launchd `ai.openclaw.stock.watchlist-monitor` (평일 09–15시 매 15분) | -| **트레일링 스톱 감시** | `python3 ~/.openclaw/agents/stock/workspace/scripts/trailing_monitor.py {check [--repeat N --gap S] [--force] [--dry-run]\|list}` | launchd `ai.openclaw.stock.trailing-monitor` (평일 09:00–15:30 **매 1분**, 스크립트가 장외·휴장 self-skip). 자산웹에서 PIN 승인된 트레일링 예약의 조건단가를 고점 갱신 시 kt10002로 **상향만** 정정. 신규 발주 없음. 예약 0건이면 즉시 종료(API 콜 0). 상태 `state/trailing_stops.json`. ⚠️ cadence 변경 이력 2분→30초→**1분**(2026-07-30 관리자님) — 30초는 launchd로 불가해 `--repeat 2 --gap 30`(프로세스 내부 2회)으로 구현했었고, 옵션은 살아있으니 다시 쓸 땐 plist 인자만 추가 | +| **트레일링 스톱 감시** | `python3 ~/.openclaw/agents/stock/workspace/scripts/trailing_monitor.py {check [--repeat N --gap S] [--force] [--dry-run]\|list}` | launchd `ai.openclaw.stock.trailing-monitor` (평일 **08:00–19:59 매 1분**, 720엔트리 — 2026-08-25 plist 실측 정정. 스크립트가 장외·휴장 self-skip). 하는 일 둘: ①자산웹에서 PIN 승인된 트레일링 예약의 조건단가를 고점 갱신 시 kt10002로 **상향만** 정정 ②**예약 트레일링(TRAILING_ARM) 발동·만료** — 발동가 도달 시 스톱주문 신규 발주 + 예약 승격, 미발동으로 등록일이 지난 대기 예약은 자동 취소(둘 다 2026-08-25 추가, 관리자님 승인). ⚠️ "신규 발주 없음"은 더 이상 사실이 아니다. 예약 0건이면 즉시 종료(API 콜 0). 상태 `state/trailing_stops.json`. ⚠️ cadence 변경 이력 2분→30초→**1분**(2026-07-30 관리자님) — 30초는 launchd로 불가해 `--repeat 2 --gap 30`(프로세스 내부 2회)으로 구현했었고, 옵션은 살아있으니 다시 쓸 땐 plist 인자만 추가 | | **공모주 캘린더 동기화** | `python3 ~/.openclaw/agents/stock/workspace/scripts/ipo_calendar_sync.py` | launchd `ai.openclaw.stock.ipo-calendar-sync` (매월 1일 09:00) | | **FOMC 캘린더 동기화** | `python3 ~/.openclaw/agents/stock/workspace/scripts/fomc_calendar_sync.py [--list\|--dry-run]` | launchd `ai.openclaw.stock.fomc-calendar-sync` (매월 1일 09:10). 연준 공식 일정표 → `[FOMC] YYYY-MM 회의` 종일 일정(회의 시작일 ET ~ 발표일 KST). `--list`는 파싱 결과만(캘린더 미접근) | | **휴장일 동기화** | `python3 ~/.openclaw/agents/stock/workspace/scripts/holiday_sync.py [--show]` | launchd `ai.openclaw.stock.holiday-sync` (매주 일요일 03:00) | diff --git a/agents/stock/workspace/MEMORY.md b/agents/stock/workspace/MEMORY.md index 663d3a01..bd0b9e9a 100644 --- a/agents/stock/workspace/MEMORY.md +++ b/agents/stock/workspace/MEMORY.md @@ -43,7 +43,7 @@ |---|---|---|---| | `watchlist-monitor` | 평일 10·12·14시 (휴장일 self-skip) | `watchlist_monitor.py check` | buy/target/stop 감지 시 텔레그램 요약 + HTML 메일 1통. 2026-05-12 15분 간격 → 3회 축소 (관리자님 요청) | | `surge-monitor` | 평일 09:00–15:35 **매 1분** (`StartCalendarInterval` 396엔트리, 스크립트 self-skip) | `surge_monitor.py check` | **급등락 알림** (2026-08-04 도입). 자산웹 🔔 토글 켜진 종목만(`state/behive_surge_toggles.json`). 조회·알림 전용이라 `feedback_no_trade_auto_triggers` 예외 아님(주문 API 경로 없음). **세 기준 병행**: 거래소 **상·하한가 도달**(2026-08-06 추가, 그날 끝까지 간 것이라 무엇에도 가리지 않음) + 거래소 **VI 발동**(급등락 여부를 거래소가 판정 → 문턱 불필요) + **자기 이력 분위수**(장중 이탈폭 ≥ 그 종목 과거 이탈폭 `SURGE_PCTL` p90, 확대는 p98). ⚠️ **고정 %를 쓰자는 제안은 거절할 근거가 있다** — 실측(관심·감시 65종목 × 280거래일, 2026-08-04) 장중 이탈폭 중간값 3.8%라 ±3%가 평범한 날이고, 종목별 변동성이 **22배 차**(ATR14 1.0~22.3%)라 ±5% 문턱이면 KODEX 미국S&P500 발생 0회 / SK이터닉스 75회다. 같은 5%가 한쪽엔 도달 불가·한쪽엔 노이즈. ⚠️ **ATR 배수도 폐기됐다 — 2026-08-04 관리자님 지적**("하루 최대 폭이 −30%~+30%인데"). 국내 가격제한폭이 ±30%인데 ATR 배수는 상한이 없어 문턱이 제한폭 밖으로 밀려난다: 1차(ATR×1.5) 5/65종목, 확대(ATR×3.0) **31/65(48%)**가 발동 불가였다. 분위수는 실제 관측값이라 구조적으로 제한폭을 못 넘고(p90~p99 전부 30% 초과 0종목) 알림량이 정의상 (1−p)로 확정된다(p90 = 종목당 연 25회). **ATR 배수 재제안 금지.** ⚠️ 평시 사이클 **키움 콜 0** — behive_web `/api/realtime/quotes` 1콜이 현재가와 VI를 함께 준다(VI 1h는 시장 전역 broadcast라 구독 무관). 구독 상한에 걸려 빠진 종목만 ka10095 배치 1콜 폴백. ⚠️ **문턱은 하루 1회만 계산**(`state/surge_thresholds.json`) — `daily_candles_cache.get_candles`가 stale 캐시에 ka10081을 때리므로 매분 × 종목수면 폭주. ⚠️ **ka10081 유량 초당 5건** — 70종목 일괄 산출 시 26종목이 429로 실패(실측). 사이클당 12개·0.35s 간격으로 나눠 계산하고(70종목 ≈ 6분) **조회 실패는 캐시하지 않는다**(이력부족과 같이 `{}`로 굳히면 429 한 번에 그 종목이 하루 내내 VI만 감시). ⚠️ **VI 레그는 behive_web 프로세스 의존** — 죽으면 VI 알림이 조용히 사라지고 stderr 경고만(백업 트리거는 `feedback_no_unsanctioned_failover`대로 의도적 부재). ⚠️ **상·하한가·VI 중 문턱을 넘으면 생략이 아니라 보류**(2026-08-06 관리자님 요청) — `surge_alerts.json`의 `__pending__`에 이탈폭 최대 시점을 적어뒀다가 가림이 풀린 사이클에 방출한다. 그전엔 버려져서 **VI 중 돌파 후 해제 시점에 되밀리면 알림이 아예 없었다**. 가림 여부는 보류 목록이 비었는지가 아니라 `evaluate`의 **가림사유 반환값**으로 판단해야 한다(VI 중 되밀림도 목록이 비어 조기 방출됨). 방출분은 **발송 성공 시에만** 보류에서 제거. VI가 마감까지 안 풀리면 그날 보류분은 방출 안 됨(강제 flush 없음). ⚠️ **상·하한가는 ka10095 `upl_pric`/`lst_pric`(거래소 계산값)** — 제한폭은 기준가 호가단위로 절사 후 ±하는 규칙이라 직접 계산하면 ETF·가격대별 표를 재현해야 해 틀린다. 장중 불변이라 `state/surge_limits.json`에 하루 1콜 캐시, 일봉 이력 불필요라 **이력 부족 종목도 감시됨**. dedup `state/surge_alerts.json` = 분위수 방향별 1회 + 확대 1회 + 상·하한가 1회, VI는 `vi:<발동시각>`이라 발동 건마다 1회(하루 상한 없음). 로그 `logs/stock-surge-monitor.{log,err.log}` | -| `trailing-monitor` | 평일 09:00–15:30 **매 1분** (`StartCalendarInterval` 391엔트리, 스크립트 self-skip) | `trailing_monitor.py check` | **트레일링 스톱 감시** (2026-07-30 도입). ⚠️ **매매 API를 자동 호출하는 유일한 트리거** — `feedback_no_trade_auto_triggers` 예외로 관리자님이 명시 승인. 허용 근거: 호출하는 건 `kiwoom_order.modify_order` **하나뿐**이고 신규 발주·수량·방향 변경 경로가 없음. PIN 승인 시 계좌·종목·수량·트레일 폭이 확정되고, 감시는 이미 승인된 스톱주문의 조건단가를 **상향만** 정정. 예약 0건이면 즉시 종료(API 콜 0). 상태 `state/trailing_stops.json`, 로그 `logs/stock-trailing-monitor.{log,err.log}`. ⚠️ **cadence 이력 2분→30초→1분**(전부 2026-07-30 관리자님 지시). launchd 최소 단위가 1분이라 30초는 프로세스 내부 `--repeat 2 --gap 30`(1분 발화 × 30초 간격 2회)으로 구현했고 **CLI 옵션은 살아있다** — 다시 30초로 갈 땐 plist ProgramArguments에 인자만 추가. `StartInterval=30`은 GUI idle 시 발화 보류라 사용 금지(sim-scan이 같은 이유로 캘린더 전환). ⚠️ **스톱주문은 장 마감 후 소멸한다 — 2026-07-30 실증 완료**(가정이 틀렸음). 첫 실주문 TRL-A2Z4(제닉스로보틱스 1주, 14:35 등록, 손절선 4,370→4,455 3회 상향)가 **체결 없이** 사라졌다: 당일매매일지 매도 기록 0건 · 보유수량 3,338주 불변 · 미체결 0건. 감지 시각 **18:21**(15:30 장 마감 즉시가 아니라 몇 시간 뒤 — 감시는 그 사이 매분 정상 발화했고 로그가 조용했으니 주문은 18:20까지 조회에 살아있었다. NXT 시간대엔 손절선이 실제로 유효했던 셈. 1회 관측이라 매일 같은 시각인지는 미확인). ⚠️ **자동 재등록 안 한다**(관리자님 결정) — 알림만 보내고 재등록은 자산웹에서 수동. 소멸 알림에 사유 구분 있음(아래). ⚠️ **예약 종료 알림 3갈래**(`_fill_check`+`_notify_gone`): 미체결 조회에서 사라진 것만으로는 체결·취소·소멸을 구분할 수 없어(셋 다 목록에서 빠짐) 예약이 사라질 때만 ka10170 1콜 추가로 판정 — ✅체결(당일 매도기록 있음, 수량·평균가·실현손익 표시. 관리자님 수동 매도와는 구분 불가라 단정 안 함) / ⚠️소멸(매도기록 없음 = 체결 안 됨 확정 + 재등록 안내) / 🎯판정불가(일지 조회 실패). **조회 실패를 '체결 안 됨'으로 단정하면 안 된다** — `_fill_check`가 (기록, 성공여부) 튜플을 반환하는 이유. ⚠️ **초기 고점 = 등록 시점 현재가 고정**, 이후 갱신도 **현재가로만**(당일고가를 쓰면 등록 전 고가 때문에 즉시 발동). ⚠️ **고점 기준 선택 옵션은 2026-07-30 제거 — 다시 넣지 말 것**(`peak_basis`/`52주 전고점`/`매수후 고점`). 과거 고점 기준은 손절선이 현재가 위로 올라가 `TRAIL_IMMEDIATE` 거부되는데, **손실 종목은 등록 자체가 불가**(실측 제닉스로보틱스 현재가 4,805 · 매수후 고점 6,890 → 필요 폭 30.3% / 52주 19,940 → 75.9%, 상한 30% 초과)고 **이익 종목은 과거 고점 ≈ 현재가라 결과가 동일**. 차이 날 때 못 쓰고 쓸 수 있을 때 차이 없어 폐기. ⚠️ **최저 매도가**(`min_sell_price`)로 손절선 하한 지정 가능 — `cond_uv = max(트레일, 최저가)`. 트레일 폭을 넓게 잡아도 이 가격 아래로 안 내려간다. 고점이 올라 트레일이 추월하면 그 뒤론 트레일 지배(전환점 = 최저가÷(1−pct)). 1분 간격이라 그 사이 스파이크는 놓친다(손절선이 덜 올라가는 방향 — 손실 위험 아님). ⚠️ **정밀도 보완 안 함으로 결론**(2026-07-30 관리자님): 최저 매도가로 하한이 통제되니 1분 해상도 누락은 감당 범위. `entry_day_high` 기준선 / `realtime_hub` WS 틱 추적 **둘 다 채택 안 함 — 재제안 금지** | +| `trailing-monitor` | 평일 08:00–19:59 **매 1분** (`StartCalendarInterval` 720엔트리, 스크립트 self-skip — 2026-08-25 plist 실측. 오래 "09:00–15:30 391엔트리"로 적혀 있었으나 사실과 달랐다. NXT 프리·애프터까지 덮는 게 맞다) | `trailing_monitor.py check` | **트레일링 스톱 감시** (2026-07-30 도입). ⚠️ **매매 API를 자동 호출하는 유일한 트리거** — `feedback_no_trade_auto_triggers` 예외로 관리자님이 명시 승인. ⚠️ **2026-08-25 부터 신규 발주 경로가 생겼다** — 그전까지의 허용 근거 "호출하는 건 `modify_order` 하나뿐이고 신규 발주 경로가 없음"은 더 이상 사실이 아니다. 지금 호출 대상은 ①`modify_order`(조건단가 **상향만** 정정) ②**예약 트레일링(TRAILING_ARM) 발동 시 `submit(STOP_LIMIT)` 신규 발주** 2종이고, ②는 관리자님 명시 승인 건이다. 바뀐 허용 근거: **계좌·종목·수량·계단·발동가가 PIN 승인 시점에 전부 확정되고 감시는 발동가 도달 판정만 한다** — 감시가 매매의 *내용*을 정하는 경로는 여전히 없다(매도가능 부족 시 **축소** 재배분만, 수량 증가·방향 변경 없음). 발동은 PIN 재확인이 없으므로 승인 카드·웹 미리보기에 그 사실을 명시해 둔다. 예약 0건이면 즉시 종료(API 콜 0). 상태 `state/trailing_stops.json`, 로그 `logs/stock-trailing-monitor.{log,err.log}`. ⚠️ **cadence 이력 2분→30초→1분**(전부 2026-07-30 관리자님 지시). launchd 최소 단위가 1분이라 30초는 프로세스 내부 `--repeat 2 --gap 30`(1분 발화 × 30초 간격 2회)으로 구현했고 **CLI 옵션은 살아있다** — 다시 30초로 갈 땐 plist ProgramArguments에 인자만 추가. `StartInterval=30`은 GUI idle 시 발화 보류라 사용 금지(sim-scan이 같은 이유로 캘린더 전환). ⚠️ **스톱주문은 장 마감 후 소멸한다 — 2026-07-30 실증 완료**(가정이 틀렸음). 첫 실주문 TRL-A2Z4(제닉스로보틱스 1주, 14:35 등록, 손절선 4,370→4,455 3회 상향)가 **체결 없이** 사라졌다: 당일매매일지 매도 기록 0건 · 보유수량 3,338주 불변 · 미체결 0건. 감지 시각 **18:21**(15:30 장 마감 즉시가 아니라 몇 시간 뒤 — 감시는 그 사이 매분 정상 발화했고 로그가 조용했으니 주문은 18:20까지 조회에 살아있었다. NXT 시간대엔 손절선이 실제로 유효했던 셈. 1회 관측이라 매일 같은 시각인지는 미확인). ⚠️ **자동 재등록 안 한다**(관리자님 결정) — 알림만 보내고 재등록은 자산웹에서 수동. 소멸 알림에 사유 구분 있음(아래). ⚠️ **예약 종료 알림 3갈래**(`_fill_check`+`_notify_gone`): 미체결 조회에서 사라진 것만으로는 체결·취소·소멸을 구분할 수 없어(셋 다 목록에서 빠짐) 레그가 사라질 때만 **kt00007**(주문번호 단위) 1콜 추가로 판정 — ✅체결(그 `ord_no` 의 체결수량>0) ⚠️2026-08-25 문서 정정: 오래 `ka10170`(종목 단위 당일매매일지)로 적혀 있었으나 **2026-07-31 계단식 전환 때 kt00007 로 교체됐다** — 종목 단위 집계를 쓰면 한 계단이 체결된 날 나머지 계단이 장 마감으로 소멸했을 때 그 소멸분까지 '체결'로 오판한다(같은 종목 매도기록이 이미 있어서) / ⚠️소멸(매도기록 없음 = 체결 안 됨 확정 + 재등록 안내) / 🎯판정불가(일지 조회 실패). **조회 실패를 '체결 안 됨'으로 단정하면 안 된다** — `_fill_check`가 (기록, 성공여부) 튜플을 반환하는 이유. ⚠️ **초기 고점 = 등록 시점 현재가 고정**, 이후 갱신도 **현재가로만**(당일고가를 쓰면 등록 전 고가 때문에 즉시 발동). ⚠️ **고점 기준 선택 옵션은 2026-07-30 제거 — 다시 넣지 말 것**(`peak_basis`/`52주 전고점`/`매수후 고점`). 과거 고점 기준은 손절선이 현재가 위로 올라가 `TRAIL_IMMEDIATE` 거부되는데, **손실 종목은 등록 자체가 불가**(실측 제닉스로보틱스 현재가 4,805 · 매수후 고점 6,890 → 필요 폭 30.3% / 52주 19,940 → 75.9%, 상한 30% 초과)고 **이익 종목은 과거 고점 ≈ 현재가라 결과가 동일**. 차이 날 때 못 쓰고 쓸 수 있을 때 차이 없어 폐기. ⚠️ **최저 매도가**(`min_sell_price`)로 손절선 하한 지정 가능 — `cond_uv = max(트레일, 최저가)`. 트레일 폭을 넓게 잡아도 이 가격 아래로 안 내려간다. 고점이 올라 트레일이 추월하면 그 뒤론 트레일 지배(전환점 = 최저가÷(1−pct)). 1분 간격이라 그 사이 스파이크는 놓친다(손절선이 덜 올라가는 방향 — 손실 위험 아님). ⚠️ **정밀도 보완 안 함으로 결론**(2026-07-30 관리자님): 최저 매도가로 하한이 통제되니 1분 해상도 누락은 감당 범위. `entry_day_high` 기준선 / `realtime_hub` WS 틱 추적 **둘 다 채택 안 함 — 재제안 금지**. ⚠️ **예약 트레일링(TRAILING_ARM, 2026-08-25 추가)** — 발동가를 미리 정해두고 현재가가 거기 **올라와 닿으면**(상승 도달만) 그 시점 현재가를 고점으로 계단 스톱주문을 발주하고 트레일링 예약으로 승격한다. PIN 은 등록 시 1회뿐이고 **발동 시 재확인 없음**(승인 근거는 위 참조). ⚠️ **대기 예약 수명 = 등록일 당일** — 관리자님이 "트레일링 감시랑 똑같이 장 마감 지나면 취소"로 지시(2026-08-25). 스톱주문은 거래소가 지워주지만 대기 예약은 우리 파일(`arms` 키)에만 있어 **우리가** 지운다. 만료 기준은 `guards.after_trading_day`(NXT 애프터 마감 **20:00**) — `session_at=='CLOSED'` 로 대신하면 09:00:00~09:00:30 틈과 새벽에도 CLOSED 라 아침에 멀쩡한 예약이 만료된다. 감시가 08:00~19:59 에만 돌아 **실제 정리는 다음 거래일 첫 사이클**, 그 사이 자산웹은 `만료 — 정리 대기` 표시(숨기면 화면이 거짓말). 만료 정리는 **시세 조회보다 앞**(시세 실패해도 정리돼야 하고 어제 예약이 오늘 시세로 발동하면 안 됨), 알림은 건수 무관 **1통**. ⚠️ **발동 후에는 일반 트레일링과 완전히 동일** — 그날 장 마감에 스톱이 소멸하고 자동 재등록 안 함. 늦게 발동하면 "발동 알림 + 소멸 알림"만 남을 수 있다. ⚠️ **크래시 안전 = 2단계 마킹**(`firing_at` 선기록 + 레그별 `ord_no` 기록). **firing_at 남은 예약은 자동 재발동 금지** — 나갔는지 모르는 상태의 재시도가 곧 이중 매도. ⚠️ **대기 수량은 매도가능에서 차감 안 함** → 발동 시 `min(예약, 매도가능)` 축소 재배분(0주면 삭제+알림). ⚠️ **사이드카는 마킹 전에 확인**(마킹 후 막히면 예약이 갇힌다) | | `briefing` | 평일 20:10 (휴장일 self-skip) | `stock_portfolio_report.py send --no-mail` | ⚠️ **메일 발송 중지 — 2026-08-12 관리자님 결정**("겹치는 부분 많고 메일 너무 많다"). 이제 이 트리거의 목적은 **오늘 스냅샷 확보뿐**이다(`state/portfolio_daily_snapshot.json` = 자산웹 '당일 평가손익'·`rebase_to_nxt_close` 전일 baseline). ⚠️ **`run` 모드로 바꾸지 말 것** — 휴장일 self-skip이 `send` 분기에만 있어 `run`이면 휴장일에도 스냅샷이 생겨 85일치 이력에 없던 휴장일 키가 섞이고, 평문 리포트 100여 줄을 로그에 쏟는다. `--no-mail`은 휴장일 가드·실패 자가알림을 유지하고 `send_mail` 호출만 건너뛴다(`MAIL_SKIPPED=no_mail_flag` 출력). 텔레그램 요약은 2026-05-14부터 이미 비활성 | | `briefing-fallback-2030` | 평일 20:30 (휴장일 self-skip) | `briefing_fallback.py retry` | 스냅샷 있으면 skip(idempotent), 없으면 `send --no-mail` 재실행. 알림 없음. ⚠️ 최근 2주 실측 단 한 번도 실제 작업 안 함(20:10이 매일 성공) — 무해한 보험이라 유지 | | `briefing-fallback-2100` | 평일 21:00 (휴장일 self-skip) | `briefing_fallback.py **force**` | 마지막 폴백 + 20:10 데이터 부정확 보완. 스냅샷 있으면 `run`으로 21:00 fresh 값 덮어쓰기, 없으면 `send --no-mail` + 실패 시 레이 텔레그램 알림. ⚠️ **plist 실제 인자는 `force`** — 이 표가 오래 `final`로 잘못 적혀 있었다(2026-08-12 수정) | diff --git a/agents/stock/workspace/SOUL.md b/agents/stock/workspace/SOUL.md index bf123a9d..9ca4eb9d 100644 --- a/agents/stock/workspace/SOUL.md +++ b/agents/stock/workspace/SOUL.md @@ -33,13 +33,16 @@ Be the assistant you'd actually want to talk to. Concise when needed, thorough w 레이는 **매매를 결정하지 않는다.** 결정권은 항상 관리자님 PIN echo 에 있다. -- **LLM 자율 매매 금지** — 워치리스트 도달·시장 이벤트·뉴스·자체 추론으로 매수/매도 자동 트리거 X. 매매는 오직 관리자님이 텔레그램에 직접 명시적으로 지시할 때만. +- **LLM 자율 매매 금지** — 워치리스트 도달·시장 이벤트·뉴스·자체 추론으로 매수/매도 자동 트리거 X. 매매는 오직 관리자님이 텔레그램에 직접 명시적으로 지시할 때만. (가격 도달로 주문이 나가는 경로가 하나 있다 — 자산웹에서 PIN 으로 사전 승인한 **예약 트레일링**뿐이고, 아래 「자동 트리거 등록 금지」의 예외 항목을 볼 것. 레이가 만들 수 있는 경로는 아니다.) - **레이의 역할 = 페이로드 추출** — 자연어("삼성 5주 75,000 원에 사줘") → 종목코드·계좌·수량·가격·주문방식을 dict 로 정리해서 `orders.handler.propose_trade` 진입점에 전달. 그 외엔 일체 매매 도구를 부르지 않는다. - **카드+PIN 분리 발송** — propose_trade 가 카드 메시지(매수/매도·계좌·종목·가격 4개 하이라이트) + PIN 메시지(단독 4자리 숫자, 가희 계좌면 8자리 영숫자) 두 개를 텔레그램에 보낸다. - **PIN echo 가 마지막 게이트** — 관리자님이 PIN 을 그대로 답장한 순간만 실주문이 나간다. 레이 LLM 이 PIN 을 자기 판단으로 만들거나 변형하면 절대 안 된다. - **사이드카 ON 상태에선 모든 매매 거부** — `state/orders_disabled` 파일 존재 시 진입점 첫 줄에서 차단. `/orders_off` `/orders_on` 으로 토글. - **조회 vs 주문 분리 유지** — `kiwoom_client.py` 는 여전히 조회 전용. 주문 함수는 `orders/` 패키지로 분리. 조회 모듈에 주문 함수 추가 금지. - **매매 관련 자동 트리거 등록 금지** — 매매·매매재시도·취소·재발행 등 `orders/` 모듈 진입점을 cron `jobs.json` 또는 launchd plist 에 자동 발화 항목으로 등록하지 않는다. 실패·거절·쿨다운된 매매는 관리자님이 텔레그램으로 다시 명시적으로 지시할 때까지 그대로 둔다. "쿨다운 끝나면 다시 시도하자"는 충동도 X — 자동 재시도 등록 자체가 LLM 자율 매매와 동급. 자동화가 필요해 보이면 직접 등록하지 말고 관리자님께 설계 안건으로 제안. + - **승인된 예외는 `ai.openclaw.stock.trailing-monitor.plist` 하나뿐**이고, 그 안에서 허용된 매매 API 는 ①`modify_order`(트레일링 손절선 **상향** 정정, 2026-07-30 승인) ②**예약 트레일링(TRAILING_ARM)의 발동가 도달 시 스톱주문 발주**(2026-08-25 승인) 2종이다. + - 허용 근거는 "발주가 없다"가 **아니라** "계좌·종목·수량·계단·발동가가 **PIN 승인 시점에 전부 확정**되고, 감시는 확정된 내용을 조건 도달 시 집행할 뿐 LLM 판단이 개입하지 않는다" 다. 판단 기준을 이렇게 읽어야 한다 — **누가 매매의 *내용*(계좌·종목·수량·방향·가격)을 정하는가.** 사람이 PIN 으로 정했으면 집행 자동화는 별건 승인 대상, 감시 루프나 LLM 이 정하면 금지. + - 이 예외를 근거로 **새 자동 트리거를 스스로 늘리지 않는다.** 감시가 수량을 늘리거나 방향을 바꾸는 경로도 여전히 없다(매도가능 부족 시 **축소**만). - **자동매매·알고리즘 매매 요청** — 이 원칙을 환기하고 사이드카·1주 검증·가드 보강을 거친 별도 설계 안건으로 미룬다. 상세 사양과 호출 진입점은 `orders/__init__.py`, 한도값은 `orders/limits.json` 참조. 사용은 `skills/order-trading`, `skills/order-controls`. diff --git a/agents/stock/workspace/scripts/behive_web.py b/agents/stock/workspace/scripts/behive_web.py index bc52446e..ce7e1c46 100644 --- a/agents/stock/workspace/scripts/behive_web.py +++ b/agents/stock/workspace/scripts/behive_web.py @@ -808,6 +808,26 @@ def _market_phase_state() -> dict: return {'phase': 'closed', 'label': '장외 시간', 'active': False, 'time': time_str} +# phase 경계 시각(분) — _market_phase_state 의 분기 경계와 같은 값. +_PHASE_BOUNDARIES_MIN = (8 * 60, 9 * 60, 15 * 60 + 30, 20 * 60) + + +def _secs_to_next_phase() -> int | None: + """오늘 남은 다음 phase 경계까지의 초. 주말·휴장일·20:00 이후엔 None + (브라우저는 기본 60초 주기로 폴링). + + ⚠️ now.second 절사 덕에 값이 항상 실제보다 크거나 같다 → 이 값으로 잡은 타이머가 + 경계보다 일찍 깨는 일이 없다(일찍 깨면 옛 phase 를 읽고 다음 경계까지 60초 주기로 밀린다).""" + now = datetime.now(KST) + if now.weekday() >= 5 or now.strftime('%Y-%m-%d') in _load_holidays(): + return None + cur = now.hour * 3600 + now.minute * 60 + now.second + for b in _PHASE_BOUNDARIES_MIN: + if b * 60 > cur: + return b * 60 - cur + return None + + def _bump_panel_signal() -> None: """주문 접수/취소 등 이벤트 발생 시 state/fill_signal 갱신 → 브라우저 1초 틱(realtime)이 fill_seq 변화를 감지해 패널을 1회 갱신. 3초 주기 전면 swap 폐지(2026-07-22, 이벤트 기반 전환) @@ -3954,6 +3974,45 @@ _TRAILING_LOOKUP_TTL = 5.0 _trailing_lookup_cache: dict = {'ts': 0.0, 'by_ord': {}} +def _arm_rows() -> list: + """대기 예약 → 미체결 목록에 섞을 합성 행. 상태파일 읽기라 키움 콜 0. + + JS 렌더러가 읽는 키를 그대로 채우고 `kind='arm'` 으로 표시한다. 취소 경로를 물리적으로 + 분리하려고 `ord_no` 는 비워 둔다 — 미체결 취소(kt10003)가 이 행을 삼키면 안 된다. + 현재가는 실시간 허브에서만 읽는다(없으면 0) — 팝업 열 때 키움 조회가 나가면 안 된다. + """ + out = [] + try: + from orders import trailing as _trl + arms = _trl.list_arms() + except Exception: + return out + hub = _RT_HUB + for a in arms: + cur = 0 + if hub is not None: + q = hub.get_quote(a['symbol']) or {} + cur = q.get('price') or 0 + out.append({ + 'account': a['account'], 'ord_no': '', 'code': a['symbol'], 'name': a['symbol_name'], + 'side': 'SELL', 'order_qty': a['qty'], 'unfilled_qty': a['qty'], + 'order_price': 0, 'order_type': '예약 트레일링', 'stop_price': 0, + 'cur_price': cur, 'exchange': '-', 'exchange_code': '', + 'status': '발동 대기', 'order_time': (a.get('created_at') or '')[5:16].replace('T', ' '), + 'trailing': None, + 'kind': 'arm', + 'arm': {'id': a['id'], 'trigger_price': a['trigger_price'], + 'min_sell_price': a.get('min_sell_price'), + 'steps': a.get('steps') or [], 'firing_at': a.get('firing_at'), + 'fired_legs': a.get('fired_legs') or [], + 'created_at': a.get('created_at'), + # 감시 루프는 08:00~19:59 에만 돌아 20:00 직후엔 아직 파일에 남아 있다. + # 숨기지 않고 '만료' 로 표시한다 — 화면이 실제 상태와 달라선 안 된다. + 'expired': _trl.is_arm_expired(a)}, + }) + return out + + def _trailing_info_for(ord_no: str): """미체결 주문번호 → 트레일링 예약 요약 (없으면 None). @@ -8495,6 +8554,7 @@ def _render_order_modal() -> str: '' '' '' + '' '' '