diff --git a/CLAUDE.md b/CLAUDE.md index 4cd8c9d..9b7b1d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,13 +99,13 @@ 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` 패턴) +- `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·모달 추가가 필요 없다 — 렌더 함수 + 엔드포인트 분기만 더하면 된다. 현재 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가 자동으로 줄어든다**(이중 매도 구조적 차단). + **매도 주문유형 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가 자동으로 줄어든다**(이중 매도 구조적 차단). **텔레그램 발송 정책 (웹 매매)** — 거부·검증 에러는 토스트만, **매매등록(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`)은 예외. @@ -154,7 +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.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.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/agents/stock/workspace/scripts/behive_web.py b/agents/stock/workspace/scripts/behive_web.py index 95b46d0..5384c59 100644 --- a/agents/stock/workspace/scripts/behive_web.py +++ b/agents/stock/workspace/scripts/behive_web.py @@ -2001,6 +2001,23 @@ def _set_stock_tag(code: str | None, name: str | None, text: str, leader: bool) tmp.replace(STOCK_TAGS) +# ---- 트레일링 계단 프리셋 (읽기 전용 · 고정 4종) ---- +# 계단 = [{'pct': 고점대비 하락률, 'cum': 그때까지 팔려 있어야 할 누적 비중}] +# 누적으로 적는 이유는 관리자님이 그렇게 생각하기 때문이다("30% 빠지면 전량") — +# 주문 수량인 추가분(20/30/50)으로의 환산은 orders/trailing.normalize_steps 가 한다. +# 순서 = 기본값이 맨 앞(단일) + 트레일 폭이 좁은 것부터. 화면에서 고른 뒤 그 자리에서 +# 고칠 수 있으므로 저장 기능은 두지 않는다(2026-07-31 관리자님 지시로 제거). +TRAIL_PRESETS = [ + {'name': '단일', 'steps': [{'pct': 10, 'cum': 100}]}, + {'name': '보수', 'steps': [{'pct': 5, 'cum': 30}, {'pct': 10, 'cum': 70}, + {'pct': 15, 'cum': 100}]}, + {'name': '표준', 'steps': [{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50}, + {'pct': 30, 'cum': 100}]}, + {'name': '느슨', 'steps': [{'pct': 15, 'cum': 25}, {'pct': 25, 'cum': 50}, + {'pct': 30, 'cum': 100}]}, +] + + def _stock_notes_lock(): """STOCK_NOTES 파일 직렬화 — _stock_tags_lock 과 동일 패턴, 별도 lock 파일.""" import fcntl as _fcntl @@ -3633,21 +3650,35 @@ def _trailing_info_for(ord_no: str): 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()} + # 예약 1건이 계단(레그) N개 = 미체결 주문 N건이라, 레그마다 (예약, 레그) 로 색인한다. + by_ord = {} + for r in _trl.list_active(): + for s in r.get('steps') or []: + by_ord[s['ord_no']] = (r, s) 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: + hit = _trailing_lookup_cache['by_ord'].get(ord_no) + if not hit: return None + r, s = hit + steps = r.get('steps') or [] return { - 'id': r['id'], 'trail_pct': r.get('trail_pct'), 'peak': r.get('peak', 0), + 'id': r['id'], 'peak': r.get('peak', 0), 'min_sell_price': r.get('min_sell_price'), - 'modify_count': r.get('modify_count', 0), + # 이 주문이 몇 단계인지 + 예약 전체가 몇 단인지 — 미체결 목록엔 레그가 따로 뜨므로 + # "3단 중 2단계" 를 보여줘야 다른 행과의 관계가 읽힌다. + 'step_n': s['n'], 'step_total': len(steps), + 'trail_pct': s.get('pct'), 'cum': s.get('cum'), + 'step_qty': s.get('qty', 0), + 'modify_count': s.get('modify_count', 0), # 등록 시점 값 — 손절선·고점이 그 뒤로 얼마 올라왔는지 보여주는 데 쓴다. - 'entry_cond_uv': r.get('entry_cond_uv', 0), + 'entry_cond_uv': s.get('entry_cond_uv', 0), 'entry_peak': r.get('entry_peak', 0), + # 형제 계단 — 이 예약의 전체 그림을 한 행에서 보여준다. + 'siblings': [{'n': x['n'], 'pct': x.get('pct'), 'cond_uv': x.get('cond_uv', 0), + 'qty': x.get('qty', 0), 'cum': x.get('cum')} for x in steps], } @@ -3737,7 +3768,19 @@ def _pending_detail_html(r: dict) -> str: # (실측 entry_peak 4,860 vs peak 4,950). 등록 후 얼마나 올랐는지는 # 아래 '고점 상승' 행이 실제 값으로 보여준다. mini.append(f'
고점
{trl.get("peak", 0):,}원
') - mini.append(f'
트레일 폭
−{trl.get("trail_pct")}%
') + cum_v = trl.get('cum') or 0 + cum_part = '전량' if cum_v >= 100 else f'누적 {cum_v:g}%' + mini.append(f'
계단
{trl.get("step_n")}단계 / ' + f'{trl.get("step_total")}단 · −{trl.get("trail_pct"):g}% ' + f'({cum_part})
') + if (trl.get('step_total') or 0) > 1: + # 형제 계단 — 미체결 목록엔 레그가 각각 뜨므로, 이 예약 전체가 어떤 모양인지 + # 한 행에서 보이게 한다. 지금 행에 해당하는 계단은 굵게. + sib = ' · '.join( + (f'{x["n"]}:{x["cond_uv"]:,}' if x['n'] == trl.get('step_n') + else f'{x["n"]}:{x["cond_uv"]:,}') + for x in (trl.get('siblings') or [])) + mini.append(f'
전체 계단
{sib}
') if trl.get('min_sell_price'): mini.append(f'
최저 매도가
{trl["min_sell_price"]:,}원
') if cur_price > 0 and stop_p: @@ -5130,10 +5173,11 @@ def _render_owner_panel(owner: str, d: dict, balances: dict, owner_label_text: s _CSS = ''' * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } html, body { margin: 0; padding: 0; overscroll-behavior-y: none; touch-action: manipulation; } -body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Apple SD Gothic Neo", sans-serif; background: #0b0d12; color: #e6e6e6; -webkit-font-smoothing: antialiased; padding-bottom: calc(env(safe-area-inset-bottom) + 28px); -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } +body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Apple SD Gothic Neo", sans-serif; background: #0b0d12; color: #e6e6e6; -webkit-font-smoothing: antialiased; padding-bottom: calc(env(safe-area-inset-bottom) + 44px); -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } -/* 하단 고정 시장 지수 — 코스피/코스닥. viewport 최하단에 박힘. iOS 홈 인디케이터 영역만 safe-area 패딩으로 양보. */ -/* 증권사 스타일 무한 가로 스크롤. 같은 항목 세트를 2번 렌더 후 -50% 이동 → seamless loop. */ +/* 하단 고정 시장 지수. viewport 최하단에 박힘. iOS 홈 인디케이터 영역만 safe-area 패딩으로 양보. */ +/* 지수 2개 = 한 묶음. 바를 탭할 때마다 다음 묶음으로 순환(마지막 → 처음). + 자동 스크롤·자동 회전 없음 — 흘러가는 마퀴는 2026-07-31 관리자님 지시로 제거됐다. 되살리지 말 것. */ .market-ticker { position: fixed; left: 0; right: 0; bottom: 0; z-index: 30; background: rgba(11,13,18,0.96); @@ -5142,43 +5186,45 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Apple padding: 5px 0; padding-bottom: calc(5px + env(safe-area-inset-bottom)); font-size: 10.5px; + /* line-height 1.2 고정: 라벨줄이 한글(닛케이225·금·은·동)이라 normal이면 ~1.45em로 늘어나 바 높이가 어긋난다. */ line-height: 1.2; font-variant-numeric: tabular-nums; color: #e6e6e6; overflow: hidden; white-space: nowrap; + /* 세로 3줄: 지수명 / 가격+등락 / 묶음 점. 점을 옆이 아니라 아래 줄 가운데로 둔 배치(2026-07-31 관리자님 지시). */ + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; + /* iOS Safari는 비대화형 div에 cursor:pointer가 없으면 click을 합성하지 않는다 — 탭 전환의 전제조건. 지우지 말 것. */ + cursor: pointer; + user-select: none; -webkit-user-select: none; } -.ticker-track { - display: inline-flex; - width: max-content; - /* intro(5s linear): viewport 오른쪽 끝(100vw)에서 자연 위치(0)까지 일정 속도로 슬라이드 인 — 첫 로딩 시 KOSPI 잘림 방지. - ease-out은 시작 순간이 peak 속도라 "엄청 빠름" 인상을 줘서 linear로 고정. - scroll(20s linear infinite, 5s 지연): intro 끝난 직후 무한 가로 스크롤로 인계. translateX(0)에서 매끄럽게 연결. */ - animation: ticker-intro 5s linear, ticker-scroll 20s linear 5s infinite; - will-change: transform; +/* 세로 flex라 flex-grow를 주면 높이가 늘어난다 → 0 0 auto + width로 가로만 채운다. + max-width: PC·아이패드에서 두 칸이 화면 절반씩 벌어져 텅 비어 보이는 걸 막는 상한. */ +.ticker-page { flex: 0 0 auto; width: 100%; max-width: 520px; display: flex; align-items: center; min-height: 26px; } +/* 탭 전환 때만 페이드. 20초 주기 자동 갱신(animate=false)은 값만 조용히 교체. */ +.ticker-page.fade { animation: ticker-fade 140ms ease-out; } +@keyframes ticker-fade { + from { opacity: 0.35; } + to { opacity: 1; } } -.ticker-set { display: inline-flex; align-items: center; gap: 20px; flex-shrink: 0; } -.ticker-sep { display: inline-flex; align-items: center; padding: 0 22px; color: #4a5060; font-size: 14px; font-weight: 700; user-select: none; } -@keyframes ticker-intro { - from { transform: translateX(100vw); } - to { transform: translateX(0); } -} -@keyframes ticker-scroll { - from { transform: translateX(0); } - to { transform: translateX(-50%); } -} -/* PC 마우스 호버 시만 일시정지. 모바일 :active는 페이지 터치 후 잔류해서 ticker가 멈춰버리는 이슈로 제외. */ -@media (hover: hover) { - .market-ticker:hover .ticker-track { animation-play-state: paused; } -} -.market-ticker .idx { display: inline-flex; align-items: baseline; gap: 4px; font-size: 10.5px; } -.market-ticker .idx-label { color: #8b8f9a; font-weight: 500; font-size: 10.5px; } -.market-ticker .idx-price { color: #f0f0f0; font-weight: 500; font-size: 10.5px; } -.market-ticker .idx-delta { font-weight: 400; font-size: 10.5px; } +.market-ticker .idx { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 1px; } +/* max-width+overflow: 칸보다 긴 내용은 오른쪽만 잘린다. .idx에 걸면 가운데정렬 탓에 양끝이 깎여 앞글자까지 사라진다. */ +.market-ticker .idx-label { max-width: 100%; overflow: hidden; text-overflow: ellipsis; color: #8b8f9a; font-weight: 500; font-size: 9.5px; } +.market-ticker .idx-val { max-width: 100%; min-width: 0; overflow: hidden; display: flex; align-items: baseline; gap: 4px; } +/* 좁은 화면에서 칸을 넘칠 때: 가격은 절대 안 줄이고(flex:0 0 auto) 등락만 말줄임. + 둘 다 기본 shrink로 두면 flex가 양쪽을 눌러 앞글자까지 깎이고 두 칸 폭도 어긋난다(320px 실측). */ +.market-ticker .idx-price { flex: 0 0 auto; color: #f0f0f0; font-weight: 500; font-size: 10.5px; } +.market-ticker .idx-delta { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-weight: 400; font-size: 10.5px; } .market-ticker .idx-delta.up { color: #ff4d5e; } .market-ticker .idx-delta.down { color: #4d9bff; } .market-ticker .idx-delta.flat { color: #8b8f9a; } -.market-ticker .idx-empty { color: #5a5f6c; font-size: 10px; padding: 0 14px; } +/* 묶음 점 — 3번째 줄 가운데(부모 align-items:center가 가로 정렬). 지수 칸과 가로 폭을 나눠쓰지 않으므로 + 묶음 수가 줄어도 칸 폭은 영향 없다(옆에 두던 시절 필요했던 min-width 고정폭은 그래서 제거됨). */ +.ticker-dots { flex: 0 0 auto; display: flex; align-items: center; gap: 3px; } +.ticker-dots i { width: 4px; height: 4px; border-radius: 50%; background: #333844; } +.ticker-dots i.on { background: #c9ccd3; } +/* min-height/line-height를 표시 상태 content 높이(26 + gap 3 + 점 4 = 33)와 맞춰 로딩↔표시 전환 때 바가 안 튀게. */ +.market-ticker .idx-empty { flex: 0 0 auto; width: 100%; text-align: center; min-height: 33px; line-height: 33px; color: #5a5f6c; font-size: 10px; } /* ── pull-to-refresh ── */ /* .page가 손가락 따라 translateY로 내려감 → topbar+컨텐츠 다 함께 내려가고 그 위로 빈공간 노출. @@ -5469,6 +5515,39 @@ dl.pending-detail dl.trail-mini dd b { color: #d9a84a; font-weight: 600; } .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; } +/* 계단 편집기 — .order-inputs 실폭이 모바일 ~200px 라 열 하나하나가 아깝다. + 행마다 "하락/누적" 글자를 반복하는 대신 헤더 한 줄로 빼고, 화살표도 지웠다. + 그 결과 입력칸이 38px → 78px 로 넓어진다(고정폭 58 → 44, 라벨·단위 21px 회수). + 스피너를 없애는 것도 폭 확보의 일부 — 데스크톱에서 화살표가 숫자를 가리던 문제도 같이 해결. */ +.trail-steps { display: flex; flex-direction: column; gap: 3px; margin-top: 5px; min-width: 0; } +.trail-step { display: grid; align-items: center; gap: 4px; min-width: 0; + grid-template-columns: 12px minmax(0,1fr) minmax(0,1fr) 16px; font-size: 10px; color: #7c8290; } +.trail-step-head { color: #5a5f6c; font-size: 9px; letter-spacing: .02em; } +.trail-step-head span { text-align: center; } +.trail-step .tsn { color: #6f7480; font-variant-numeric: tabular-nums; text-align: center; } +.trail-step .tsw { display: flex; align-items: center; gap: 2px; min-width: 0; } +.trail-step input { width: 100%; min-width: 0; padding: 4px 5px; font-size: 12px; + background: #0f131c; border: 1px solid #262b38; border-radius: 5px; color: #cfd3dc; + font-variant-numeric: tabular-nums; text-align: right; } +.trail-step input::-webkit-outer-spin-button, +.trail-step input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +.trail-step .tsu { color: #6f7480; flex: 0 0 auto; font-size: 9px; } +.trail-step .tsx { background: none; border: 0; color: #5a5f6c; cursor: pointer; font-size: 12px; + padding: 0; line-height: 1; } +.trail-step .tsx:hover { color: #e05555; } +.trail-add-btn { width: 100%; margin-top: 5px; padding: 5px 6px; font-size: 10px; cursor: pointer; + background: #171b26; border: 1px dashed #2c3140; border-radius: 6px; color: #8b8f9a; } +.trail-add-btn:hover { background: #1d2230; color: #cfd3dc; border-color: #3a4152; } +/* 미리보기 계단 목록 — 한 줄에 넷을 욱여넣으면(계단·가격·주수·누적) 200px 를 넘겨 접힌다. + 1행 "N단계 −P% … 가격", 2행 "주수 · 누적" 으로 나눠 가격을 우측 정렬로 세운다. */ +.trail-pv-steps { display: flex; flex-direction: column; gap: 5px; font-size: 10px; + border-top: 1px solid #1a1e28; padding-top: 6px; min-width: 0; } +.trail-pv-step { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 1px 6px; + align-items: baseline; font-variant-numeric: tabular-nums; } +.trail-pv-step .sp { color: #7c8290; } +.trail-pv-step .sv { color: #cfd3dc; font-weight: 600; text-align: right; } +.trail-pv-step .sq { grid-column: 1 / -1; color: #6f7480; font-size: 9px; } +.trail-pv-step.tied .sv { color: #d9a84a; } .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; } @@ -5930,9 +6009,9 @@ nav.tabs a.tab-label:hover { color: #c9ccd3; } .tab-content { display: none; } /* 짧은 탭(자산정보 등)도 viewport를 채워 모바일 Safari 주소창 토글이 일관되게 동작하도록. ticker(fixed bottom) 위치가 다른 탭과 시각적으로 동일해진다. */ -section.tab-content { min-height: calc(100dvh - 110px); } +section.tab-content { min-height: calc(100dvh - 126px); } @supports not (height: 100dvh) { - section.tab-content { min-height: calc(100vh - 110px); } + section.tab-content { min-height: calc(100vh - 126px); } } /* ── 계좌현황 패널 ── */ @@ -8080,16 +8159,14 @@ def _render_order_modal() -> str: '