diff --git a/CLAUDE.md b/CLAUDE.md index df8a61c..4cd8c9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,10 +99,14 @@ Stock agent (`agents/stock/workspace/scripts/`), run with `python3`: - `holiday_sync.py` — investing.com에서 한국(KRX) 휴장일 fetch → `state/market_holidays.json`. behive_web.py 자동갱신 토글이 휴장일·평일·시간대로 비활성 판정. 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 재활용 +- `trailing_monitor.py` — **트레일링 스톱 감시** (2026-07-30). 키움 REST에 트레일링 주문 TR이 없어서, 스톱지정가(`trde_tp=28`) 주문을 실제로 걸어두고 고점 갱신 시 `kt10002` 정정(`mdfy_cond_uv`)으로 조건단가를 올려 구현. **주문이 키움 서버에 있어 감시 루프가 죽어도 마지막 손절선은 살아있다** (자체 폴링 발주 방식은 루프가 죽으면 손절이 통째로 사라짐 — 이 차이가 설계 선택 이유). 사이클당 하는 일: ①`ka10075` 미체결 조회로 예약 생존 확인(계좌당 1콜). 사라지면 정리 + **사유 구분 알림** — 미체결 조회만으로는 체결·취소·소멸이 구분되지 않아(셋 다 목록에서 빠짐) 그때만 `ka10170` 1콜 추가로 판정: ✅체결(당일 매도기록 있음) / ⚠️소멸(기록 없음 = 체결 안 됨 확정, 재등록 안내) / 🎯판정불가(일지 조회 실패). ⚠️ **조회 실패를 '체결 안 됨'으로 단정 금지** — `_fill_check` 가 (기록, 성공여부) 튜플을 반환하는 이유 ②`ka10095` 시세 1콜 → 고점 갱신분만 정정. 주가가 그대로·하락이면 API 콜 0. ⚠️ **`kt10002` 응답 `ord_no`는 신규 주문번호** — `trailing.commit_modify`가 상태파일 `ord_no`를 갱신하지 않으면 두 번째 정정부터 `orig_ord_no`가 틀려 전부 실패(최대 함정). ⚠️ **고점은 등록 시점 현재가에서 시작해 현재가로만 갱신**한다 — `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`) + 손절선 계산. `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틱 — 조건단가와 같게 두면 발동 후 그 가격 아래로 안 팔려 미체결로 남고, 너무 벌리면 급락 시 헐값 매도). 반환에 `floor_applied`(최저가가 트레일을 이겼는지)를 실어 카드·미리보기가 어느 쪽이 지배 중인지 표시한다. **최저 매도가**는 손절선의 하한 — 트레일 폭을 넓게 잡아도 이 가격 아래로 안 내려간다(초기 구간 손실 제한). 고점이 올라 트레일이 최저가를 추월하면 그 뒤론 트레일이 지배(전환점 = `min_sell_price ÷ (1−pct/100)`, 예: 최저 4,500·20% → 5,625원). 최저가 지배 구간에선 고점이 올라도 조건단가가 안 변해 `next_levels`가 None → 불필요한 정정 API 콜 0. `next_levels(res, cur)`는 **상향 전용 순수함수**(현재가>고점 AND 새 조건단가>기존 조건단가일 때만 값 반환) — 단위테스트 대상. 트레일 폭 허용 0.5~30%. 같은 계좌·종목 중복 예약은 `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·모달 추가가 필요 없다 — 렌더 함수 + 엔드포인트 분기만 더하면 된다. 현재 2개: `/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`). **트레일링 입력 2개**(단가 입력행은 숨김 — 조건단가·지정가는 서버 계산): ①**트레일 폭 %**(0.5~30, 기본 10) ②**최저 매도가**(선택, 비우면 제한 없음). 단위(`(%)`·`(원)`)는 **라벨에 박는다** — 입력칸 옆에 붙이면 가로로 벌어져 스크롤이 생기고 number input 안쪽 absolute 접미사는 데스크톱 스피너와 겹친다. `handler.propose_trade(trail_pct, min_sell_price)`가 초기 고점을 `md['current_price']`로 정하고 `trailing.compute_levels`로 계산. ⚠️ **고점 기준 선택 옵션은 2026-07-30 제거됨**(`peak_basis` / `52주 전고점` / `매수후 고점`) — 과거 고점을 기준으로 잡으면 손절선이 현재가 위로 올라가 등록 즉시 발동한다. **손실 종목은 등록 자체가 불가**(실측 제닉스로보틱스 현재가 4,805 · 매수후 고점 6,890 → 필요 폭 30.3% / 52주 19,940 → 75.9%, 둘 다 상한 30% 초과), **이익 종목은 과거 고점 ≈ 현재가라 결과가 같다**. 즉 차이가 날 때는 못 쓰고 쓸 수 있을 때는 차이가 없어 옵션을 없앴다. **다시 넣지 말 것.** ⚠️ **손절선 ≥ 현재가면 `TRAIL_IMMEDIATE`로 거부** — 고점=현재가 고정이라 이제 최저 매도가가 현재가보다 높은 경우만 남는다. **거부 시점은 입력 시점**(2026-07-30 관리자님 지시로 propose 시점에서 이동) — `trailBlockReason()`이 입력값을 보고 사유 문자열을 내놓고, 걸리면 매도 버튼이 disable + title 에 사유. 서버 `TRAIL_IMMEDIATE`는 최후 방어선으로 유지. ⚠️ **버튼 `disabled`는 `refreshSubmitGate()` 한 곳에서만 결정한다** — 시장 phase 게이트(`updateMarketPhaseDisplay`, `/api/market_state` 주기 폴링)와 트레일 게이트가 같은 버튼을 건드려서, 예전처럼 각자 대입하면 주기 호출이 트레일 게이트를 덮어써 즉시발동 입력이 통과된다. phase 쪽은 `state.phaseCanTrade`/`phaseLabel`만 쓰고 판단을 위임. propose 진행 중에는 `btn.dataset.busy='1'`로 잠가 주기 호출이 버튼을 되살리지 못하게 하고, 응답·에러 후 busy 해제 + 재계산. `renderTrailPreview`는 조기 반환 경로(폭 미입력·시세 로딩)에서도 게이트가 걸려야 하므로 **함수 맨 앞에서** `refreshSubmitGate()`를 부른다. 클라이언트 미리보기(`renderTrailPreview`/`trailCondPrice`/`floorTick`)는 서버와 **같은 식**이지만 실제 발주값은 서버 계산분이 승자 — 한쪽만 바꾸면 표시와 발주가 어긋나니 `orders/trailing.compute_levels` 수정 시 JS도 함께 고칠 것(112개 조합 node 대조로 검증하는 방식). 키움엔 스톱지정가로 나가고(`submit_with_pin`이 `TRAILING_STOP`→`STOP_LIMIT` 변환) 트레일링은 우리 쪽 개념. 승인 후 `trailing.register(ord_no, ...)`로 예약 등록, 이후 `trailing_monitor`가 조건단가 상향. **등록 실패 시 주문은 이미 접수된 상태**라 카드 메시지에 ⚠️ 경고를 붙이고 ledger `trailing_register_failed` 기록(숨기면 트레일링이 안 도는 걸 모른다). 트레일링·스톱지정가는 단일 계좌 예약이라 **2계좌 동시 매도 분기에서 제외**. 취소는 `[📋 진행중]`에서 해당 미체결 주문 취소 → 다음 감시 사이클이 사라진 예약을 자동 정리. ⚠️ 트레일링 예약이 미체결 매도로 잡혀 있으면 `/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으로 초록·수신버블이 생기고 자동입력이 깨질 수 있음. 타인 대상(예: 가희 리마인더 `gahee_reminder._send_imessage`는 `--service sms`)은 예외. **신규 endpoint**: `/api/quote_book?code` (ka10004 호가 10단계 + ka10001 현재가 + `nxt_enable`), `/api/order/check?code&account&side&price` (잔액·보유·max_qty·매도 손익 미리보기), `POST /api/order/propose` (handler.propose_trade wrapper, 성공 시만 텔레그램·iMessage), `POST /api/order/propose_multi` (2계좌 동시 매도 — accounts/qtys 콤마 동수 리스트, handler.propose_trade_multi wrapper), `POST /api/order/verify` (handler.submit_with_pin wrapper, 성공 시만 텔레그램), `POST /api/order/cancel` (handler.cancel_active_card, 텔레그램 X), `/api/order/active` (PinStore.peek + 4계좌 미체결 카운트), `/api/orders/open` (ka10075 4계좌), `POST /api/orders/cancel?ord_no&account` (handler.cancel_open_order + 텔레그램), `/api/market_state` (regular/nxt/closed/holiday/weekend phase), `/api/symbols/all` (보유+관심+감시 통합 dedup) @@ -150,6 +154,7 @@ OpenClaw 자동화는 두 갈래로 동작한다 (모두 Asia/Seoul): - **stock.briefing-fallback-2030** — 평일 20:30 — 오늘 스냅샷 없으면 stock.briefing 재실행 (idempotent) - **stock.briefing-fallback-2100** — 평일 21:00 — **무조건 fresh fetch로 스냅샷 갱신** (`briefing_fallback.py force` → 스냅샷 있으면 `stock_portfolio_report.py run` 메일·텔레그램 X, 없으면 `send` 폴백 + 실패 시 알림). 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). 로그 `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.ipo-calendar-sync** — 매주 금요일 17:00 — 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 자동갱신 토글이 참조) diff --git a/TASKS.md b/TASKS.md index f99ae36..c13397e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -18,6 +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/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 8244045..eecffe9 100644 --- a/agents/stock/workspace/MEMORY.md +++ b/agents/stock/workspace/MEMORY.md @@ -42,6 +42,7 @@ | 라벨 (`ai.openclaw.stock.`) | 일정 | 스크립트 | 핵심 비고 | |---|---|---|---| | `watchlist-monitor` | 평일 10·12·14시 (휴장일 self-skip) | `watchlist_monitor.py check` | buy/target/stop 감지 시 텔레그램 요약 + HTML 메일 1통. 2026-05-12 15분 간격 → 3회 축소 (관리자님 요청) | +| `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 틱 추적 **둘 다 채택 안 함 — 재제안 금지** | | `briefing` | 평일 20:10 (휴장일 self-skip) | `stock_portfolio_report.py send` | 메일만 발송. 텔레그램 리포트 요약은 2026-05-14 관리자님 요청으로 비활성화. 실패 시 자가 알림 | | `briefing-fallback-2030` | 평일 20:30 (휴장일 self-skip) | `briefing_fallback.py retry` | 스냅샷 있으면 skip(idempotent), 없으면 send 재실행. 알림 없음 | | `briefing-fallback-2100` | 평일 21:00 (휴장일 self-skip) | `briefing_fallback.py final` | 마지막 폴백. 실패 시 레이 텔레그램 알림 | diff --git a/agents/stock/workspace/scripts/behive_web.py b/agents/stock/workspace/scripts/behive_web.py index 338015a..95b46d0 100644 --- a/agents/stock/workspace/scripts/behive_web.py +++ b/agents/stock/workspace/scripts/behive_web.py @@ -1509,6 +1509,10 @@ def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict: 'qty': qty, 'price': o.get('order_price', 0), 'order_type': o.get('order_type', ''), + # 스톱지정가 조건단가 (ka10075 stop_pric) + 트레일링 예약이면 그 상태. + # 상세 표시가 "지정가"로 뭉개지지 않게 주문 성격을 그대로 실어 보낸다. + 'stop_price': o.get('stop_price', 0), + 'trailing': _trailing_info_for(o.get('ord_no', '')), 'time': o.get('order_time', ''), 'account': o.get('account', ''), 'exchange': o.get('exchange', ''), @@ -3612,10 +3616,65 @@ def _fmt_hhmmss(raw: str) -> str: return s +_TRAILING_LOOKUP_TTL = 5.0 +_trailing_lookup_cache: dict = {'ts': 0.0, 'by_ord': {}} + + +def _trailing_info_for(ord_no: str): + """미체결 주문번호 → 트레일링 예약 요약 (없으면 None). + + `state/trailing_stops.json` 읽기라 키움 콜 0. 렌더 경로에서 주문마다 불리므로 5초 캐시. + ⚠️ 예약의 ord_no 는 정정할 때마다 바뀌므로(kt10002 가 새 번호 발급) 항상 예약 파일의 + 현재 ord_no 와 매칭한다 — 등록 시점 번호로 매칭하면 첫 상향 후 연결이 끊긴다. + """ + if not ord_no: + return None + now = time.time() + if now - _trailing_lookup_cache['ts'] > _TRAILING_LOOKUP_TTL: + try: + from orders import trailing as _trl + by_ord = {r['ord_no']: r for r in _trl.list_active()} + except Exception: + by_ord = {} + _trailing_lookup_cache['by_ord'] = by_ord + _trailing_lookup_cache['ts'] = now + r = _trailing_lookup_cache['by_ord'].get(ord_no) + if not r: + return None + return { + 'id': r['id'], 'trail_pct': r.get('trail_pct'), 'peak': r.get('peak', 0), + 'min_sell_price': r.get('min_sell_price'), + 'modify_count': r.get('modify_count', 0), + # 등록 시점 값 — 손절선·고점이 그 뒤로 얼마 올라왔는지 보여주는 데 쓴다. + 'entry_cond_uv': r.get('entry_cond_uv', 0), + 'entry_peak': r.get('entry_peak', 0), + } + + +def _stop_gap_html(stop_price: int, limit_price: int) -> str: + """스톱 주문의 조건단가−지정가 갭 표기. 호가단위로 나눠 틱수로 보여준다. + + `orders/trailing.ORD_UV_GAP_TICKS` 값을 화면에 그대로 박지 않는 이유는 실제 주문에 + 걸린 값과 어긋날 수 있기 때문(정정 중 값 변화·키움앱 수동 개입). 실측값으로 역산한다. + 틱으로 딱 나눠지지 않으면 원으로 폴백. + """ + gap = (stop_price or 0) - (limit_price or 0) + if gap <= 0: + return '' + try: + from orders.trailing import tick_size + t = tick_size(stop_price) + except Exception: + t = 0 + if t > 0 and gap % t == 0: + return f' −{gap // t}틱' + return f' −{gap:,}원' + + def _pending_detail_html(r: dict) -> str: """자세히보기용 미체결 ord 단위 상세. 매수/매도 분리 표시. `r['pending_orders']` 가 ord별 dict 리스트. - 레이아웃: dt(라벨, 컬러) / dd 2줄(상단=수량@가격, 하단=계좌·시각·주문번호 muted). + 레이아웃: dt(라벨, 컬러) / dd 2~3줄(상단=수량@가격, 조건줄, 하단=계좌·시각·주문번호 muted). """ orders = r.get('pending_orders') or [] if not orders: @@ -3623,11 +3682,14 @@ def _pending_detail_html(r: dict) -> str: buys = [o for o in orders if o.get('side') == 'BUY'] sells = [o for o in orders if o.get('side') == 'SELL'] + cur_price = r.get('price', 0) or 0 # 조건단가까지 남은 거리 계산용 + def _row(o: dict, kind: str) -> str: qty = o.get('qty', 0) price = o.get('price', 0) otype = o.get('order_type') or '' - price_str = f'{price:,}원 지정가' if price else (otype or '시장가') + stop_p = o.get('stop_price', 0) or 0 + trl = o.get('trailing') acc = html.escape(o.get('account', '')) ord_no = html.escape(o.get('ord_no', '')) tm = _fmt_hhmmss(html.escape(o.get('time', ''))) @@ -3636,11 +3698,72 @@ def _pending_detail_html(r: dict) -> str: meta_parts.append(tm) if ord_no: meta_parts.append(f'주문번호 {ord_no}') + label = '미체결 매수' if kind == 'buy' else '미체결 매도' + if kind == 'sell' and trl: + label = '트레일링 매도' + elif kind == 'sell' and stop_p: + label = '스톱 매도' + + # 스톱 계열은 가격이 두 개다 — 조건단가(방아쇠)와 지정가(실제 주문가). + # 첫 줄에 지정가만 '@ N원' 으로 박으면 그게 방아쇠처럼 오해되고 둘의 관계도 안 보인다. + # 그래서 트레일링은 첫 줄에 수량만 남기고 두 가격을 미니표 맨 위 단독 항으로 올린다. + if trl or stop_p: + main = f'{qty:,}주 매도 예약' + elif price: + main = f'{qty:,}주 @ {price:,}원 {otype}' if otype else f'{qty:,}주 @ {price:,}원 지정가' + else: + main = f'{qty:,}주 @ {otype or "시장가"}' + + cond_line = '' + if stop_p and not trl: + # 순수 스톱지정가 — 조건단가 고정. 표 없이 한 줄이지만 순서는 트레일링과 같게 + # (조건가 닿으면 → 지정가로 매도). 지정가가 먼저 오면 그게 방아쇠로 오해된다. + tail = f' {price:,}원에 매도' if price else ' 매도' + cond_line = (f'
' + f'{stop_p:,}원 닿으면{tail}{_stop_gap_html(stop_p, price)} ' + f'(고정)
') + if trl: + mini = [] + mini.append(f'
손절선
{stop_p:,}원 도달 시
') + if price: + # 갭은 두 값의 차를 호가단위로 나눠 틱수로 표시 — ORD_UV_GAP_TICKS 를 그대로 + # 박지 않는 이유는 실제 주문 값과 어긋날 수 있어서다(정정·수동 개입). + # 틱으로 딱 안 나눠지면 원으로 폴백. + gap_part = _stop_gap_html(stop_p, price) + mini.append(f'
매도 지정가
{price:,}원{gap_part}
') + # 고점에 '등록 시점' 라벨을 붙이지 않는다 — peak 는 감시가 갱신한 현재 고점이라 + # 붙여 놓으면 지금 고점이 등록 시점 값인 것처럼 읽힌다 + # (실측 entry_peak 4,860 vs peak 4,950). 등록 후 얼마나 올랐는지는 + # 아래 '고점 상승' 행이 실제 값으로 보여준다. + mini.append(f'
고점
{trl.get("peak", 0):,}원
') + mini.append(f'
트레일 폭
−{trl.get("trail_pct")}%
') + if trl.get('min_sell_price'): + mini.append(f'
최저 매도가
{trl["min_sell_price"]:,}원
') + if cur_price > 0 and stop_p: + # 손절선을 기준으로 현재가가 몇 % 위에 있는지 = 팔리기까지 남은 여유. + # 라벨이 '손절선 대비'이므로 분모도 손절선이어야 라벨과 계산이 어긋나지 않는다. + room = (cur_price - stop_p) / stop_p * 100 + mini.append(f'
손절선 대비
{room:+.1f}% ' + f'(현재 {cur_price:,}원)
') + # 상향 횟수는 표시하지 않는다(관리자님 지시). mc 는 상승 행들의 표시 조건으로만 쓴다. + mc = trl.get('modify_count', 0) + entry_peak = trl.get('entry_peak') or 0 + peak_now = trl.get('peak', 0) + if entry_peak and peak_now > entry_peak: + mini.append(f'
고점 상승
{entry_peak:,} → {peak_now:,}원 ' + f'+{peak_now - entry_peak:,}
') + entry_cond = trl.get('entry_cond_uv') or 0 + if mc and entry_cond and stop_p > entry_cond: + mini.append(f'
손절선 상승
{entry_cond:,} → {stop_p:,}원 ' + f'+{stop_p - entry_cond:,}
') + cond_line += f'
{"".join(mini)}
' + return ( f'
{label}
' f'
' - f'
{qty:,}주 @ {price_str}
' + f'
{main}
' + f'{cond_line}' f'
{" · ".join(meta_parts)}
' f'
' ) @@ -3994,151 +4117,6 @@ def _render_phantom_row(r: dict) -> str: ''' -def _render_pending_buy_held_row(r: dict) -> str: - """보유 종목 중 매수 미체결 걸린 행. 매수등록 섹션 (보유분) 카드. - summary는 매수 대기 합계·평균 주문가, 보유 수량·평단도 함께. detail은 _pending_detail_html이 주문 단위 상세 출력.""" - stock = html.escape(r['stock']) - code = html.escape(r.get('code', '') or '') - accounts = html.escape('+'.join(r.get('accounts', []))) - qty = r.get('qty', 0) - avg = r.get('avg', 0) - price = r.get('price', 0) - buy_qty = r.get('pending_buy_qty', 0) - - orders = [o for o in (r.get('pending_orders') or []) if o.get('side') == 'BUY'] - priced = [o for o in orders if o.get('price', 0) > 0] - if priced: - total = sum(o['qty'] for o in priced) - avg_buy = sum(o['qty'] * o['price'] for o in priced) // total if total else 0 - else: - avg_buy = 0 - - # 매수 주문 평균가 vs 현재가 → 추가매수 거리 - if avg_buy and price: - gap = price - avg_buy - gap_pct = (gap / avg_buy * 100) if avg_buy else 0.0 - gcls = 'up' if gap > 0 else ('down' if gap < 0 else 'neutral') - gsign = '+' if gap >= 0 else '' - diff_html = f'{gsign}{gap:,.0f}{gsign}{gap_pct:.2f}%' - else: - diff_html = '' - - price_html = f'{price:,}' if price else '-' - - summary_line2 = f'매수 대기 {buy_qty:,}주' - if avg_buy: - summary_line2 += f' · 평균 주문가 {avg_buy:,}원' - if qty and avg: - summary_line2 += f' · 보유 {qty:,}주 @ {avg:,}' - summary_line2 += '' - - chart_block = '' - if code: - chart_block = f'
' - - detail_pairs: list[str] = [f'
종목코드
{code}
'] - if price: - detail_pairs.append(f'
현재가
{price:,}원
') - if avg: - detail_pairs.append(f'
평단가
{avg:,}원
') - if avg_buy: - detail_pairs.append(f'
주문 평균가
{avg_buy:,}원
') - if qty: - detail_pairs.append(f'
보유 수량
{qty:,}주
') - detail_pairs.append(f'
매수 대기
{buy_qty:,}주
') - - row_key = (code or stock) + ':pending-buy' - return f'''
- -
-
{stock}매수등록{accounts}
-
{summary_line2}
-
-
-
{price_html}
- {f'
{diff_html}
' if diff_html else ''} -
-
-
-
{''.join(detail_pairs)}
- {_pending_detail_html(r)} - {chart_block} -
-
''' - - -def _render_pending_sell_held_row(r: dict) -> str: - """보유 종목 중 매도 미체결 걸린 행. 매도등록 섹션 전용 카드. - summary는 매도 대기 합계·평균 주문가, 보유 수량·평단도 함께. detail은 _pending_detail_html이 주문 단위 상세 출력.""" - stock = html.escape(r['stock']) - code = html.escape(r.get('code', '') or '') - accounts = html.escape('+'.join(r.get('accounts', []))) - qty = r.get('qty', 0) - avg = r.get('avg', 0) - price = r.get('price', 0) - sell_qty = r.get('pending_sell_qty', 0) - - orders = [o for o in (r.get('pending_orders') or []) if o.get('side') == 'SELL'] - priced = [o for o in orders if o.get('price', 0) > 0] - if priced: - total = sum(o['qty'] for o in priced) - avg_sell = sum(o['qty'] * o['price'] for o in priced) // total if total else 0 - else: - avg_sell = 0 - - if avg_sell and avg: - gap = avg_sell - avg - gap_pct = (gap / avg * 100) if avg else 0.0 - gcls = 'up' if gap > 0 else ('down' if gap < 0 else 'neutral') - gsign = '+' if gap >= 0 else '' - diff_html = f'{gsign}{gap:,.0f}{gsign}{gap_pct:.2f}%' - else: - diff_html = '' - - price_html = f'{price:,}' if price else '-' - - summary_line2 = f'매도 대기 {sell_qty:,}주' - if avg_sell: - summary_line2 += f' · 평균 주문가 {avg_sell:,}원' - if qty and avg: - summary_line2 += f' · 보유 {qty:,}주 @ {avg:,}' - summary_line2 += '' - - chart_block = '' - if code: - chart_block = f'
' - - detail_pairs: list[str] = [f'
종목코드
{code}
'] - if price: - detail_pairs.append(f'
현재가
{price:,}원
') - if avg: - detail_pairs.append(f'
평단가
{avg:,}원
') - if avg_sell: - detail_pairs.append(f'
주문 평균가
{avg_sell:,}원
') - if qty: - detail_pairs.append(f'
보유 수량
{qty:,}주
') - detail_pairs.append(f'
매도 대기
{sell_qty:,}주
') - - row_key = (code or stock) + ':pending-sell' - return f'''
- -
-
{stock}매도등록{accounts}
-
{summary_line2}
-
-
-
{price_html}
- {f'
{diff_html}
' if diff_html else ''} -
-
-
-
{''.join(detail_pairs)}
- {_pending_detail_html(r)} - {chart_block} -
-
''' - - def _render_pending_unheld_row(r: dict) -> str: """미보유 + 미체결 매수 주문 종목. 보유·당일정산 아닌 신규 매수 대기 행. summary는 종목명·미체결 합계, detail은 _pending_detail_html이 주문 단위로 출력.""" @@ -5058,8 +5036,13 @@ def _render_owner_panel(owner: str, d: dict, balances: dict, owner_label_text: s parts.append( f'
매수등록{buy_total}
' ) + # 보유 종목의 매수등록 카드도 보유종목 카드를 그대로 쓴다 (2026-07-30 관리자님 지시, + # 매도등록과 동일한 이유·방식). pending_buy_held 원소가 consolidated 행 객체 자체다. + # ⚠️ pending_unheld(미보유 + 매수 미체결)는 보유 수량·평단·손익이 없어 종목카드로 + # 대체할 수 없다 — 전용 카드 유지. for r in sorted(pending_buy_held, key=lambda x: -x.get('pending_buy_qty', 0)): - parts.append(_render_pending_buy_held_row(r)) + parts.append(_render_holding_row(r, d['total_value'], show_day_change, + key_suffix=':pending-buy')) for r in pending_unheld: parts.append(_render_pending_unheld_row(r)) @@ -5069,8 +5052,14 @@ def _render_owner_panel(owner: str, d: dict, balances: dict, owner_label_text: s parts.append( f'
매도등록{len(pending_sell_held)}
' ) + # 매도등록 카드는 보유종목 카드를 그대로 쓴다 (2026-07-30 관리자님 지시). + # pending_sell_held 원소가 consolidated(보유종목) 행 객체 자체라 필요한 필드가 다 있고, + # 전용 카드는 summary 값을 detail 에서 되풀이하면서 손익·버튼·태그가 없어 축소판이었다. + # 같은 종목이 자산정보 탭 보유종목과 겹치므로 key_suffix 로 row_key 를 분리한다 + # (바로 아래 ':held' 와 같은 방식 — mutex/open 복원 충돌 방지). for r in sorted(pending_sell_held, key=lambda x: -x.get('pending_sell_qty', 0)): - parts.append(_render_pending_sell_held_row(r)) + parts.append(_render_holding_row(r, d['total_value'], show_day_change, + key_suffix=':pending-sell')) # 당일매매 발생한 종목도 보유 종목 섹션에 그대로 표시 — 양쪽 다 보이게 함. # 같은 종목이 양쪽에 들어가니 row_key에 ':held' suffix를 붙여 mutex/open 복원 충돌 방지. @@ -5446,6 +5435,42 @@ dl.pending-detail dt.pending-sell-label { color: #7fb8ff; } dl.pending-detail dd { line-height: 1.45; } dl.pending-detail .pending-line-main { font-variant-numeric: tabular-nums; } dl.pending-detail .pending-line-meta { margin-top: 2px; } +/* 스톱·트레일링 조건단가 줄 — 좁은 폭에서 줄바꿈되게(가로 스크롤 방지) */ +dl.pending-detail .pending-line-cond { margin-top: 2px; color: #8b8f9a; overflow-wrap: anywhere; } +dl.pending-detail .pending-line-cond.trailing { color: #d9a84a; } +/* 트레일링 근거 미니표 — dd 안에 중첩되는 dl. 라벨 좌측 / 값 우측정렬로 자리수를 맞춘다. + 두 열 모두 minmax(0,..) 라 좁은 폭에서도 안 넘친다. */ +dl.pending-detail dl.trail-mini { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); + gap: 1px 8px; margin: 4px 0 0; padding: 5px 7px; font-size: 10.5px; + border: 1px solid rgba(217,168,74,0.22); border-radius: 6px; background: rgba(217,168,74,0.05); } +dl.pending-detail dl.trail-mini dt { color: #8b8f9a; font-weight: 400; white-space: nowrap; } +dl.pending-detail dl.trail-mini dd { margin: 0; text-align: right; color: #a8adb8; line-height: 1.45; + font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } +dl.pending-detail dl.trail-mini dd b { color: #d9a84a; font-weight: 600; } +/* ⚠️ 보유종목 행(.row.mode-held)의 `.detail dl` 은 4열 grid(80px 1fr 80px 1fr)이고 명시도가 + (0,3,1) 로 위 `dl.pending-detail dl.trail-mini` (0,2,2) 보다 높다. 되돌리지 않으면 미니표가 + 4열로 펼쳐져 고정 80px 두 개 때문에 폭을 넘겨 잘린다. pending-detail 도 같은 이유로 + 바로 위에서 2열로 되돌려 놓았다. 이 규칙은 (0,4,1) 이라 640px 미디어쿼리 안의 + 4열 규칙(0,3,1)까지 함께 이긴다 — 미디어쿼리용 사본은 필요 없다. */ +.row.mode-held .detail dl.trail-mini { grid-template-columns: minmax(0, auto) minmax(0, 1fr); } +/* 트레일링 미리보기 카드 — .order-inputs 실폭이 좁아(모바일 ~200px) 가로로 벌어지는 요소를 + 두면 가로 스크롤이 생긴다. 전부 세로 쌓기 + minmax(0,..) + anywhere 로 묶는다. */ +.trail-pv { border: 1px solid #1f2330; border-radius: 8px; padding: 8px 9px; background: #0f131c; + display: flex; flex-direction: column; gap: 7px; min-width: 0; overflow-wrap: anywhere; } +.trail-pv-hero { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.trail-pv-hero .cap { font-size: 10px; color: #7c8290; letter-spacing: .02em; } +.trail-pv-hero .val { font-size: 17px; font-weight: 700; color: #cfd3dc; font-variant-numeric: tabular-nums; line-height: 1.15; } +.trail-pv-hero .sub { font-size: 10px; color: #7c8290; } +.trail-pv.bad { border-color: rgba(224,85,85,0.45); background: rgba(224,85,85,0.07); } +.trail-pv.bad .val { color: #e05555; font-size: 13px; } +.trail-pv-kv { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 2px 8px; + font-size: 11px; border-top: 1px solid #1a1e28; padding-top: 6px; } +.trail-pv-kv dt { color: #7c8290; } +.trail-pv-kv dd { margin: 0; text-align: right; color: #a8adb8; font-variant-numeric: tabular-nums; } +.trail-pv-kv dd b { color: #cfd3dc; font-weight: 600; } +.trail-pv-note { font-size: 10px; color: #6f7480; line-height: 1.45; border-top: 1px solid #1a1e28; padding-top: 6px; } +.trail-pv-badge { display: inline-block; font-size: 9px; padding: 1px 5px; border-radius: 999px; + background: #2a2313; color: #d9a84a; margin-left: 4px; vertical-align: middle; } .caret { color: #4a4f5a; font-size: 10px; transition: transform 0.2s; } .row[open] .caret { transform: rotate(180deg); color: #8b8f9a; } @@ -6578,7 +6603,11 @@ table.adr-info-table td.adr-breakdown { font-size: 11px; color: #8b8f9a; white-s .qty-step-buttons button[data-qty-step="max"] { background: #2a1f24; color: #ffcc77; } .qty-step-buttons button[data-qty-step="clear"] { background: #1d1418; color: #ff8a95; } .order-inputs input:disabled, .order-inputs select:disabled { opacity: 0.5; cursor: not-allowed; } -.order-inputs { display: flex; flex-direction: column; gap: 8px; min-height: 0; overflow-y: auto; } +/* overflow-x: hidden 필수 — overflow-y:auto 만 주면 브라우저가 가로도 auto 로 만들어, + 내부가 조금만 넘쳐도 가로 스크롤이 생긴다(.orderbook 도 같은 이유로 명시). */ +.order-inputs { display: flex; flex-direction: column; gap: 8px; min-height: 0; overflow-y: auto; overflow-x: hidden; } +/* 폼 컨트롤이 부모보다 넓어지는 것 자체를 막는다 — select 는 내재 최소폭을 갖는다 */ +.order-inputs input, .order-inputs select { max-width: 100%; box-sizing: border-box; } /* 입력이 모달 높이를 넘쳐도 매수/취소 버튼은 항상 하단 고정 — 잘림 방지 */ .order-inputs .order-actions { margin-top: auto; position: sticky; bottom: 0; background: #0d1018; padding-top: 8px; } .order-inputs label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: #8b8f9a; } @@ -8038,11 +8067,33 @@ def _render_order_modal() -> str: '' '' '' + '' '' '' + # 트레일링: 폭(%)만 입력받고 조건단가·지정가는 서버가 현재가 기준으로 계산. + # 미리보기는 클라이언트에서 같은 식으로 계산해 보여주되 실제 값은 서버 계산분이 승자. + # 고점 기준 select 제거(2026-07-30) — 과거 고점(52주·매수후) 기준은 손절선이 현재가 + # 위로 올라가 등록 즉시 발동하는 구조라 쓸 수 없었다. 초기 고점은 등록 시점 현재가 고정. + '' '