Initial commit: OpenClaw 워크스페이스 버전관리 시작

설정·스크립트·스킬·문서·큐레이션 메모리 추적.
시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)·
백업(clobbered/bak)·dream 캐시는 .gitignore로 제외.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-06-04 15:39:41 +09:00
commit fed3526b20
199 changed files with 49671 additions and 0 deletions
@@ -0,0 +1,114 @@
---
name: behive-watchlist
description: 비하이브투자자문 유튜브 '종목분석' 영상에서 추출·저장한 목표가/매입가/손절가/요약 워치리스트 + 수동 추가·삭제 + 장중 15분 간격 시세 모니터링(buy/target/stop 트리거 → 레이 텔레그램). "OO 목표가 얼마야?", "삼성전자 워치리스트 추가해줘(매입 7만, 목표 8.5만)", "라온로보틱스 지워줘" 같은 요청에 사용.
---
# behive-watchlist
비하이브투자자문 채널의 '종목분석' 영상이 새로 올라올 때마다(cron: 평일 07/12/18시), 관리자님 이메일로 요약 리포트가 발송되고 동시에 `state/behive_watchlist.json`에 종목별 구조화 데이터가 축적된다. 관리자님이 **수동으로 직접 추가**한 종목(`source: manual`)도 같은 파일에서 관리된다.
워치리스트 종목은 **평일 장중 15분 간격**으로 `watchlist_monitor.py`가 키움 현재가와 비교해 `buy`(매입가 근접)·`target`(목표가 도달)·`stop`(손절가 이탈) 트리거를 감지한다. 조건별 하루 1회 알림이며, 한 실행에서 걸린 항목을 **텔레그램에는 요약만 묶어서**, 상세 내용은 **이메일 한 통으로 묶어서** 발송한다. LLM 판단은 거치지 않고 순수 파이썬으로 동작.
## 언제 쓰는가
관리자님이 **워치리스트 조회/추가/삭제**를 요청할 때. 예시:
- "비하이브에서 분석한 종목 뭐 있어?" → `list`
- "삼미금속 목표가 얼마야?" → `show 삼미금속`
- "펨트론 비하이브 분석 내용 보여줘" → `show 펨트론`
- "삼성전자 매수 7만, 목표 8.5만, 손절 6.5만으로 워치리스트 추가" → `add`
- "라온로보틱스 워치리스트에서 지워줘" → `remove 라온로보틱스`
**주의**: 보유 중인 종목 현황은 `kiwoom-rest` 스킬로 조회(`kt00018`). 실시간 시세도 키움 `ka10001`(`kiwoom_client.py quote`) 사용. 이 스킬은 **관심종목 관리**만 담당.
## Commands
스크립트: `/Users/snowoyh/.openclaw/agents/stock/workspace/scripts/behive_youtube_digest.py`
```bash
# 전체 종목 테이블 (종목명, 목표가, 상승률, 매입가, 저장일)
python3 scripts/behive_youtube_digest.py list
# 특정 종목 상세 (목표가/매입가/손절가/현재가/주요내용/기타/출처)
# 현재가는 키움 ka10001에서 실시간 조회되어 자동 포함됨.
python3 scripts/behive_youtube_digest.py show 삼미금속
# 종목명 일부만 적어도 부분일치 자동 매칭 (1건 매칭 시)
python3 scripts/behive_youtube_digest.py show 펨 # → 펨트론
# 수동 추가 (관리자님이 "OO 워치리스트 추가"라고 할 때)
# --code 생략 시 키움 ka10099 캐시로 종목명→코드 자동 매핑 (최초 1회 갱신)
python3 scripts/behive_youtube_digest.py add 삼성전자 \\
--buy 70000 --target 85000-90000 --stop 65000 \\
--note "반도체 슈퍼사이클 초입"
# target은 단일값("85000") 또는 범위("85000-90000") 둘 다 가능.
# 워치리스트에서 제거 (관리자님이 "OO 지워줘"라고 할 때)
python3 scripts/behive_youtube_digest.py remove 삼미금속
```
### 시세 감시 (수동 실행용)
```bash
# 1회 감시 — 장외 시간이면 즉시 스킵
python3 scripts/watchlist_monitor.py check
# 강제 실행 (장외·주말 포함)
python3 scripts/watchlist_monitor.py check --force
# 조건 평가만 — 텔레그램 발송 없이 결과 프린트
python3 scripts/watchlist_monitor.py dry-run
```
## 답변 가이드
- `show` 출력을 그대로 관리자님께 전달. 불필요한 재포맷 금지.
- 저장일시를 확인해서 "며칠 전 분석이에요"라고 맥락 첨부.
- `show` 출력에 현재가가 자동 포함됨(키움 ka10001). 조회 실패 시 "현재가: 조회 불가"로 표기.
- 관리자님이 "목표가까지 몇 %" 같은 계산을 추가로 원하면 `show`의 현재가와 목표가로 한 줄 계산해서 덧붙임.
- 관리자님이 보유 종목인지 궁금해하면 `kiwoom-rest` 스킬(`kt00018`)로 현재 보유 종목과 대조해 덧붙임.
- 수동 추가 요청 시 **종목코드는 생략**하고 종목명만 받아 `add`에 넘기면 됨 (키움 캐시가 자동 매핑). 매입/목표/손절 중 일부만 있어도 OK — 누락 필드는 해당 트리거가 비활성화될 뿐.
## Watchlist 스키마 (state/behive_watchlist.json)
종목명을 키로 하는 딕셔너리. 같은 종목이 재분석되면 최신 덮어쓰기. 수동 추가는 `video.source = "manual"`.
```json
{
"삼미금속": {
"stock": "삼미금속",
"code": "012210",
"target": {"raw": "16,000~17,000원", "low": 16000, "high": 17000},
"buy": {"raw": "13,000원대 조정", "primary": 13000, "levels": [13000]},
"stop": {"raw": "12,000원 이탈", "value": 12000},
"upside_pct": 23.1,
"summary": ["...", "..."],
"notes": ["...", "..."],
"video": {"id": "o9NBtRP1R3Y", "title": "...", "url": "...", "published": "..."},
"saved_at": "2026-04-23T07:00:00+09:00"
}
}
```
## 트리거 조건 (watchlist_monitor.py)
| 조건 | 발동 | 알림 헤더 |
|---|---|---|
| `buy` | 현재가 ≤ `buy.level × 1.05` (매입가 5% 이내 접근, 다중 레벨이면 가장 높은 1개만) | 텔레그램 `[매수구간 진입]` 요약 + 상세 이메일 |
| `target` | 현재가 ≥ `target.low` (목표가 하단 도달) | 텔레그램 `[목표가 도달]` 요약 + 상세 이메일 |
| `stop` | 현재가 ≤ `stop.value` (손절가 이탈) | 텔레그램 `[손절가 이탈]` 요약 + 상세 이메일 |
- 조건별 **하루 1회** 알림 (종목+조건 단위 중복 방지, 상태는 `state/watchlist_alerts.json`).
- 필드가 비어있으면 해당 조건 비활성.
- 보유 종목은 `kt00018` 결과 재활용으로 ka10001 호출 절감.
## Files
- Script: `scripts/behive_youtube_digest.py` (fetch/save/add/email/notify/list/show/remove)
- Monitor: `scripts/watchlist_monitor.py` (check / dry-run, 장외 시간 자동 스킵)
- Watchlist: `state/behive_watchlist.json`
- Alert dedup: `state/watchlist_alerts.json`
- Stock code cache: `state/stock_codes.json` (키움 ka10099, lazy 갱신)
- Seen 기록: `state/behive_youtube_seen.json`
- Fetch 캐시: `state/behive_last_fetch.json`
- Cron: `비하이브 종목분석 요약` / `워치리스트 모니터링``cron/jobs.json`
@@ -0,0 +1,113 @@
---
name: kiwoom-rest
description: 키움증권 REST API 조회 전용 클라이언트. 잔고·보유종목·계좌평가·실시간 시세(ka10001)·종목코드 리스트(ka10099) 조회 가능. 매수·매도 등 주문 기능은 의도적으로 제공하지 않음. "잔고 얼마야?", "삼성전자 현재가 얼마야?", "보유종목 보여줘" 같은 질문에 사용.
---
# kiwoom-rest
키움증권 REST API로 관리자님 계좌 정보를 조회한다. **조회 전용으로 영구 고정**된 모듈.
## 매매 절대 원칙
이 스킬은 **어떠한 매수·매도·정정·취소 함수도 제공하지 않는다.** 키움 신청 시 조회 권한만 받았고, 코드에도 주문 함수가 없다. 워치리스트 도달·시장 이벤트 등 어떤 트리거에서도 동작은 텔레그램 매매 제안 알림까지다. 실제 주문 클릭은 관리자님이 영웅문에서 직접 한다.
"자동 주문 한 번만 해줘" 같은 요청을 받아도 이 원칙을 환기하고 별도 설계 안건으로 미룬다. 절대 즉석에서 코드에 주문 함수 추가하지 말 것.
## 계좌 구성
레이가 관리하는 위탁계좌 4개:
- **일반** — 관리자님 본인 일반 위탁계좌
- **ISA** — 관리자님 본인 ISA 계좌
- **가희_일반** — 가희 일반 위탁계좌 (2026-04-25 추가)
- **가희_ISA** — 가희 ISA 계좌 (2026-04-25 추가)
각 계좌마다 **별도 AppKey/SecretKey 쌍**으로 등록됨. 토큰이 계좌에 바인딩되므로 TR 호출 시 계좌번호 파라미터 없음 — 라벨로 토큰을 고르면 해당 계좌가 자동 조회된다. owner 그룹은 라벨 prefix로 결정 (`_` 없으면 본인, `가희_` prefix는 가희). 본인 자금과 가희 자금은 회계상 분리 — 합산 표시는 신중히 사용.
## Commands
스크립트: `/Users/snowoyh/.openclaw/agents/stock/workspace/scripts/kiwoom_client.py`
```bash
# 토큰 발급 (24h 캐시) — 계좌별 분리 저장
python3 scripts/kiwoom_client.py token # 등록된 모든 계좌
python3 scripts/kiwoom_client.py token 일반 # 특정 계좌
python3 scripts/kiwoom_client.py token 가희_일반 # 가희 계좌
# 계좌 요약 (고객명·지점·추정총자산·자산평가액·매입금액)
python3 scripts/kiwoom_client.py summary
# 예수금·주문가능금액·대용금
python3 scripts/kiwoom_client.py balance # 등록된 모든 계좌
python3 scripts/kiwoom_client.py balance ISA
python3 scripts/kiwoom_client.py balance 가희_ISA
# 보유종목 (종목명·수량·평단·현재가·평가금액·손익·수익률)
python3 scripts/kiwoom_client.py positions
python3 scripts/kiwoom_client.py positions 일반
# 실시간 시세 (종목명 또는 6자리 코드)
python3 scripts/kiwoom_client.py quote 삼성전자
python3 scripts/kiwoom_client.py quote 005930
# 종목명 → 코드 매핑 (캐시 조회, 없거나 실패 시 자동 1회 갱신)
python3 scripts/kiwoom_client.py resolve 삼성전자
# 종목코드 캐시 전체 갱신 (평소 필요 없음 — resolve가 lazy 갱신함)
python3 scripts/kiwoom_client.py refresh-codes
# 당일매매일지 — round-trip·풀매도 포함 (잔고에 없는 거래도 잡힘)
python3 scripts/kiwoom_client.py journal # 등록된 모든 계좌 (오늘)
python3 scripts/kiwoom_client.py journal 일반 # 특정 계좌
python3 scripts/kiwoom_client.py journal 일반 20260428 # 특정 일자
```
파이썬 모듈 import:
```python
from kiwoom_client import (
list_accounts, get_balance, get_positions, get_account_summary, get_positions_all,
get_trade_journal, get_trade_journal_all,
get_stock_quote, resolve_stock_code, refresh_stock_codes,
)
for acc in list_accounts():
bal = get_balance(acc['label']) # 예수금·주문가능·대용금
summary = get_account_summary(acc['label']) # 고객명·지점·추정총자산
positions = get_positions(acc['label']) # 보유종목 리스트
trades = get_trade_journal(acc['label']) # 당일매매일지 (round-trip 포함)
all_positions = get_positions_all() # {'일반': [...], 'ISA': [...], '가희_일반': [...], '가희_ISA': [...]}
all_trades = get_trade_journal_all() # 당일 거래 — round-trip·풀매도 포함
info = resolve_stock_code('삼성전자') # {code,name,market} — lazy cache
q = get_stock_quote(info['code']) # {price,change,change_pct,volume,...}
```
## TR / 엔드포인트 매핑
Base: `https://api.kiwoom.com`
계좌 API: `POST /api/dostk/acnt` + `api-id` 헤더
- `kt00001` 예수금상세현황 (body: `{qry_tp:"3"}`)
- `kt00004` 계좌평가현황 (body: `{qry_tp:"0", dmst_stex_tp:"KRX"}`) — `acnt_nm`·`brch_nm` 포함
- `kt00018` 계좌평가잔고내역 (body: `{qry_tp:"1", dmst_stex_tp:"KRX"}`) — 종목별 상세, `poss_rt`(보유비중) 포함. **현재 보유분만 반환** — 잔고가 0인 round-trip·풀매도 종목은 빠짐
- `ka10170` 당일매매일지 (body: `{base_dt:"YYYYMMDD", ottks_tp:"1", ch_crd_tp:"0"}`) — 종목별 매수·매도·실현손익. 응답 `tdy_trde_diary[]` 필드: `stk_cd`/`stk_nm`/`buy_qty`/`buy_avg_pric`/`buy_amt`/`sell_qty`/`sel_avg_pric`/`sell_amt`/`pl_amt`(실현)/`prft_rt`(매수금액 대비 %)/`cmsn_alm_tax`(수수료+세금). 빈 응답은 stk_cd가 빈 placeholder 1건. **잔고에 안 남는 거래까지 잡으려면 이 TR 필수**
종목/시세 API: `POST /api/dostk/stkinfo` + `api-id` 헤더
- `ka10001` 주식기본정보 (body: `{stk_cd:"005930"}`) — `cur_prc`, `pred_pre`, `flu_rt`
- `ka10099` 종목정보 리스트 (body: `{mrkt_tp:"0"|"10"}`) — KOSPI(0)/KOSDAQ(10), 페이지네이션(`cont-yn`/`next-key` 헤더)
모든 숫자는 zero-padded string (음수는 `-` prefix). 클라이언트가 int로 파싱함.
## 답변 가이드
- 관리자님이 "잔고", "보유종목", "수익률" 등을 물으면 이 스킬로 조회. 응답에 **계좌 라벨 표기** (혼합 표시 원칙 — 평소 통합, 명시 요청 시 분리).
- 시세 조회(`quote`)는 키움 공식 ka10001 사용. 종목명 입력 시 `ka10099` 캐시로 자동 매핑.
- 비하이브 분석 의견(`skills/behive-watchlist`)과 보유 현황을 교차해서 답할 수 있음 — 예: "삼미금속 비하이브 목표가 16,000원, 현재 보유 50주(평단 14,200원), 도달 시 +12.6% 수익".
- "주문해줘" / "매수해줘" / "팔아줘" 류 요청 → **거부 + 매매 절대 원칙 안내**. 영웅문에서 직접 실행 부탁드린다고 응답.
## Files
- Script: `scripts/kiwoom_client.py` (read-only, 주문 함수 없음)
- Credentials: `credentials/kiwoom.json` (예제: `credentials/kiwoom.json.example`)
- Token cache: `state/kiwoom_tokens/{일반,ISA,가희_일반,가희_ISA}.json` (expires_dt 기준 자동 갱신)
- Stock code cache: `state/stock_codes.json` (ka10099 — lazy 갱신, 주기 갱신 없음)
- Portfolio ground truth: `memory/portfolio.json` (v2 스키마 — `accounts.{일반,ISA,가희_일반,가희_ISA}.positions`)
- 관련 메모리: `kiwoom_read_only.md` (영구 원칙), `kiwoom_design.md` (설계 결정·엔드포인트 매핑)
@@ -0,0 +1,89 @@
---
name: order-controls
description: 매매 PIN echo 와 매매 모듈 제어. 4자리 숫자 또는 8자리 영숫자만 단독으로 들어온 메시지(예 "4791", "K8aR3nFq")는 활성 카드의 PIN echo. /orders_off /orders_on /cancel /orders_status 슬래시 명령 + 자연어 변형("매매 풀어줘", "매매 잠가줘", "매매 상태", "카드 취소") 모두 처리. 자연어 매매 지시(매수/매도)는 order-trading 스킬을 사용.
---
# order-controls
활성 매매 카드의 PIN echo, 매매 모듈 사이드카 토글, 카드 취소, 상태 조회.
## PIN echo 처리
### 트리거 조건
관리자님 텔레그램 메시지가 **다음 조건을 모두** 만족할 때:
- 단독 메시지 (다른 텍스트 없음)
- 4자리 숫자만 (`^\d{4}$`) **또는** 8자리 영숫자 (혼동글자 0/O/o/1/l/I 제외, `^[A-Za-z2-9]{8}$`)
- 직전에 `order-trading` 카드가 발행된 상태
이 경우 PIN echo 로 간주하고 다음을 호출:
```bash
cd ~/.openclaw/agents/stock/workspace/scripts
python3 -m orders.handler pin <PIN> --live --send
```
`--live` 가 실주문 활성화. `--send` 가 결과를 텔레그램으로 자동 발송 (체결 알림·접수 알림·거부 메시지). skill 응답은 짧게 ("PIN 처리 완료" 또는 결과 요약).
### 안 부르는 경우
- "삼성 5주 75000원" 같은 자연어 → `order-trading`
- "사줘" 같은 의도 메시지 → `order-trading`
- 4자리 숫자가 다른 텍스트와 함께 있는 경우 (예: "75000원에") → PIN 아님
- 활성 카드 없을 때 PIN 형태 메시지 → 활성 카드 없음 응답이 자동 반환됨
### PIN 검증 실패 응답
- 만료: `⏱️ [#XXX] 승인 만료...`
- 불일치: `❌ [#XXX] PIN 불일치. 카드 무효...`
- 사이드카 ON: `🚫 매매 비활성화 상태...`
자동으로 텔레그램에 발송됨. skill 응답은 짧게.
## 매매 제어 명령
### 자연어 ↔ 슬래시 매핑
| 자연어 변형 | 슬래시 | 동작 |
|---|---|---|
| "매매 풀어줘", "매매 활성화", "사이드카 풀어줘", "주문 활성화" | `/orders_on` | 사이드카 OFF |
| "매매 잠가줘", "매매 비활성화", "매매 중단해줘", "매매 멈춰", "매매 중지", "사이드카 잠가줘", "주문 막아줘" | `/orders_off` | 사이드카 ON |
| "매매 상태", "사이드카 상태", "주문 상태" | `/orders_status` | 사이드카·활성 카드 상태 |
| "카드 취소", "방금 거 취소", "취소", "0" 단독 입력 — **활성 카드 있을 때 한정** | `/cancel` | 활성 카드 즉시 무효 |
자연어와 슬래시 둘 다 인식. 동일 CLI 호출.
### `/orders_off` — 매매 비활성화 (kill switch)
```bash
python3 -m orders.handler cmd /orders_off
```
응답: `🚫 매매 비활성화. /orders_on 으로 재개.` 진행 중 카드는 다음 PIN echo 시 차단.
### `/orders_on` — 매매 활성화
```bash
python3 -m orders.handler cmd /orders_on
```
응답: `✅ 매매 활성화` 또는 `⚠️ 이미 활성화 상태`.
### `/cancel` — 활성 카드 즉시 취소
```bash
python3 -m orders.handler cmd /cancel
```
응답: `❌ [#XXX] 매수 취소되었습니다.` 또는 활성 카드 없음.
### `/orders_status` — 사이드카·활성 카드 상태
```bash
python3 -m orders.handler cmd /orders_status
```
JSON 형태 status 반환. 관리자님께는 핵심만 정리해서 응답 (사이드카 ON/OFF, 활성 카드 유무·종목·만료까지 N초).
## 사이드카 직접 조작 (CLI 보조)
```bash
python3 -m orders.sidecar status
python3 -m orders.sidecar disable [reason]
python3 -m orders.sidecar enable
```
## 절대 원칙
- **PIN 변형 금지** — 관리자님이 보낸 PIN 을 그대로 echo. trim 정도만 OK. 한 글자라도 바꾸면 안 됨.
- **PIN 추측·생성 금지** — LLM 이 자기 판단으로 PIN 을 만들거나 시도하면 안 됨. 활성 카드 PIN 은 관리자님 텔레그램에만 노출됨.
- **단축어 외 매매 트리거 X** — 이 스킬은 PIN/제어만 담당. 자연어 매매는 `order-trading`.
- **"주문 취소" / "미체결 취소" / "ord_no XXX 취소" 자연어는 order-trading 으로 위임** — 이 스킬의 `/cancel`**PIN 미입력 단계 활성 카드만** 무효화한다. 키움에 이미 접수된 미체결 주문 취소(kt10003)는 `order-trading``cancel-order` 진입점이다. 활성 카드 없는 상태에서 "취소" 자연어가 오면 미체결 취소 의도일 가능성이 크므로 `order-trading` 으로 라우팅.
@@ -0,0 +1,332 @@
---
name: order-trading
description: 관리자님이 명시적으로 매수/매도를 지시하거나 활성 카드의 수량/가격을 수정할 때만 사용. "삼성 5주 75000원에 사줘", "카카오 10주 시장가 매수", "ISA에서 005930 3주 팔아줘", "삼성전자 100만원어치 매수해줘" 같은 신규 매매 지시 + "수량 20주로 바꿔줘", "가격 76000원으로", "예산 200만원으로" 같은 카드 수정 지시. 0 입력("0주로", "취소") → 카드 취소. **키움에 이미 접수된 미체결 주문**의 정정/취소("미체결 취소해줘", "ord_no 123 가격 76000으로 정정")도 이 스킬에서 처리. 워치리스트 도달이나 시장 이벤트, 자체 추론으로는 절대 트리거하지 않음. PIN 답장(4~8자리)이나 매매 제어(/orders_off 등)는 order-controls 스킬을 사용.
---
# order-trading
관리자님 매매 지시 → 페이로드 추출 → `orders.handler.propose_and_send` 호출 → 카드+PIN 분리 발송. 결정권은 PIN echo 에 있다.
## 절대 원칙
1. **자율 매매 금지** — 워치리스트 도달·뉴스·시장 이벤트로는 절대 트리거 X. 관리자님 명시적 지시("사줘"/"팔아줘"/"매수"/"매도")가 있을 때만.
2. **추측·할루시네이션 금지** — 종목명·수량·가격·계좌가 모호하면 카드 발행하지 말고 되묻는다.
3. **사이드카 ON 상태면 시도 X**`state/orders_disabled` 존재 시 거부 메시지가 반환됨. 그 메시지 그대로 관리자님께 전달.
4. **카드 발행 후 확인문은 한 줄만**`--send`가 카드+PIN을 텔레그램에 직접 보내므로 장황한 안내(`PIN 회신 대기`, `120초 만료` 등)는 보내지 않는다. 성공 시 레이는 `[계좌명] [종목명] [매수/매도] 카드 발급완료` 형식의 짧은 한국어 한 줄만 보낸다. 예: `가희 일반 LG전자 매수 카드 발급완료`. 단, 거부·오류·모호해서 되묻는 경우는 그대로 안내한다.
## 페이로드 추출 규칙
자연어에서 다음을 식별:
- **account** — "본인 일반"·"일반"·"ISA"·"가희 일반"·"가희_일반"·"가희 ISA"·"가희_ISA". **명시 없으면 무조건 되묻기**. 자동 default 없음. "가희" 만 있고 "일반/ISA" 미명시여도 되묻기. 4계좌 모호함 방지가 default 자동 가정보다 우선.
- **side** — "사", "매수", "buy" → `BUY` / "팔", "매도", "sell" → `SELL`.
- **symbol** — 6자리 종목코드면 그대로. 종목명("삼성", "삼성전자", "카카오")이면 `kiwoom_client.resolve_stock_code` CLI 로 코드 변환.
- **symbol_name** — 한글 종목명. resolve 결과 또는 사용자 입력.
- **qty** — 숫자(주). "5주"·"5". **budget 과 동시 지정 불가**.
- **budget** — 원 단위 금액. "100만원어치"·"50만원"·"1,000,000원어치"·"30만원으로". qty 대신 입력. 매수/매도 둘 다 가능.
- 환산: 키움 호가창 기준 1주 가격으로 floor 나눗셈. 매수 = 매도1호가, 매도 = 매수1호가.
- **BUY +1 정책**: 1주 가격(매도1호가) ≤ 300,000원 이고 floor 잔액이 0보다 크면 1주 더 매수. 예산을 살짝 초과해서 14주처럼. 30만원 초과 고가주는 floor 유지. SELL 은 항상 floor (보유수량 초과 매도 방지).
- 정수 주식만. 카드에는 잔액(또는 bumped 시 초과액)·매수가능금액(BUY)·보유수량(SELL) 표시.
- 예산이 1주 가격보다 작으면 `BUDGET_TOO_SMALL` 거부.
- 키움에는 무조건 시장가(`trde_tp=3`)로 발주. 환산 가격은 우리 내부 계산용일 뿐 키움에 안 감.
- 자연어 단위 정리: "100만원" → 1000000, "50만" → 500000, "1.5억" → 150000000. 천단위 콤마 제거.
- **"전액매수" / "예수금 다 써서" / "예수금 전부로" / "전부 매수"** → `kiwoom_client.get_balance(account_label)` 결과의 **`ord_alow_amt`(주문가능금액)** 를 `--budget` 으로 사용. `ord_alow_amt`가 0/누락이면 `d2_entra` 폴백. **`entr`은 절대 사용 X** (미정산 매도대금 포함되어 부풀려진 값). `d2_entra`를 빼는 계산(`entr - d2_entra` 등) **절대 금지** — D+2는 "이틀 후 도착"이 아니라 "결제 사이클 끝났을 때의 진짜 잔액"이며 그 자체가 가용 (auto-memory `reference_kiwoom_deposit_basis.md` 참조).
- **order_type** — 가격 명시되면 `LIMIT`. "시장가" 명시 → `MARKET`. "지금 바로", "즉시", "빨리", "당장" 명시 → `AGGRESSIVE_LIMIT`. **budget 입력은 자동 `MARKET`** (예산 기반은 시장가만 지원). 그 외 가격 미명시면 되묻기.
- **price** — `LIMIT` 일 때만. 천단위 콤마/원 표기 정리("75,000원" → 75000).
- **routing_force** — 보통 명시 X. 사용자가 "넥스트레이드로", "NXT로" → `NX`. "정규장으로", "KRX로" → `KRX`. 명시 없으면 SOR 자동.
추출 결과를 dict 로 정리한 후 CLI 한 번 호출.
## 진입점 (CLI)
```bash
cd ~/.openclaw/agents/stock/workspace/scripts
# 수량 입력
python3 -m orders.handler propose <account> <side> <symbol> <symbol_name> <qty> <order_type> [price] [routing] --send
# 예산 입력 (qty 자리 '-' + --budget <원>)
python3 -m orders.handler propose <account> <side> <symbol> <symbol_name> - MARKET --budget <won> [routing] --send
# 활성 카드 수정 — qty/price/order_type/budget/routing 중 하나 이상
python3 -m orders.handler amend [--qty N] [--price W] [--order-type LIMIT|MARKET|AGGRESSIVE_LIMIT] [--budget W] [--routing AL|NX|KRX] --send
# 활성 카드 취소
python3 -m orders.handler cancel --send
# 키움에 접수된 미체결 주문 조회
python3 -m orders.handler open-orders [--account A]
# 키움에 접수된 미체결 주문 취소 (PIN 없음, 안전 방향)
python3 -m orders.handler cancel-order [--ord-no N] [--account A] --live --send
# 키움에 접수된 미체결 주문 정정 (PIN 없음)
python3 -m orders.handler modify-order [--ord-no N] [--account A] [--qty Q] [--price P] --live --send
```
`--send` 플래그가 카드 + PIN 두 메시지를 텔레그램으로 자동 분리 발송한다. skill 응답은 발행 결과 요약만.
**`--live`**: 미체결 정정/취소(`cancel-order`/`modify-order`)는 안전을 위해 기본 dry-run. 실주문은 `--live` 명시 필요.
## 예시
**예 1 — 본인 일반 매수 지정가**
- 입력: "삼성 5주 75000원에 사줘"
- 명령:
```bash
python3 -m orders.handler propose 일반 BUY 005930 삼성전자 5 LIMIT 75000 --send
```
- 응답: `[계좌명] [종목명] [매수/매도] 카드 발급완료`
**예 2 — 가희 ISA 매도 시장가 (NXT 시간대 외)**
- 입력: "가희 ISA에서 카카오 10주 시장가로 팔아줘"
- 명령:
```bash
python3 -m orders.handler propose 가희_ISA SELL 035720 카카오 10 MARKET --send
```
- 응답: `[계좌명] [종목명] [매수/매도] 카드 발급완료`
**예 3 — 공격적 지정가**
- 입력: "삼성 3주 지금 바로 사줘"
- 명령:
```bash
python3 -m orders.handler propose 일반 BUY 005930 삼성전자 3 AGGRESSIVE_LIMIT --send
```
- 응답: `[계좌명] [종목명] [매수/매도] 카드 발급완료`
**예 3-2 — 예산 기반 시장가 매수**
- 입력: "삼성전자 100만원어치 매수해줘" (계좌는 직전 컨텍스트 또는 되묻기로 확정 가정)
- 명령 (일반 계좌일 때):
```bash
python3 -m orders.handler propose 일반 BUY 005930 삼성전자 - MARKET --budget 1000000 --send
```
- 응답: `[계좌명] [종목명] [매수/매도] 카드 발급완료` 카드 자체에 예산 환산 정보가 표시됨.
**예 3-3 — 예산이 1주 가격보다 작음**
- 입력: "LG전자 5만원어치 사줘" (1주 ≈ 85,000원)
- 명령:
```bash
python3 -m orders.handler propose 일반 BUY 066570 LG전자 - MARKET --budget 50000 --send
```
- 응답 (카드 발행 X, 거부): "⛔ 거부 [BUDGET_TOO_SMALL]: 1주 가격(85,000원, 매도1호가)이 예산(50,000원)보다 큽니다"
**예 3-4 — 예산 기반 매도**
- 입력: "ISA에서 카카오 50만원어치 팔아줘"
- 명령:
```bash
python3 -m orders.handler propose ISA SELL 035720 카카오 - MARKET --budget 500000 --send
```
- 응답: `[계좌명] [종목명] [매수/매도] 카드 발급완료` 카드 자체에 예산 환산 정보가 표시됨.
**예 4 — 계좌 미명시 → 넘버링 되묻기**
- 입력: "삼성전자 1주 시장가로 사줘"
- 응답 (카드 발행 X):
```
어느 계좌에서 매수할까요?
1) 일반
2) ISA
3) 가희_일반
4) 가희_ISA
```
- 사용자가 숫자(`1`~`4`) 또는 라벨(`일반`/`ISA`/`가희_일반`/`가희_ISA`) 회신 시 직전 매매 의도와 합쳐 propose CLI 호출.
**예 5 — "가희" 만 있고 일반/ISA 미명시 → 넘버링 되묻기**
- 입력: "가희로 카카오 10주 사줘"
- 응답 (카드 발행 X):
```
가희 어느 계좌인가요?
1) 가희_일반
2) 가희_ISA
```
- 숫자(`1`~`2`) 또는 라벨 회신 시 propose 호출.
**예 6 — 모호한 수량/가격은 되묻기**
- 입력: "삼성 좀 사줘"
- 응답: "어느 계좌, 몇 주(또는 얼마어치), 가격(또는 시장가)을 알려주세요. 예: `일반에서 삼성 5주 75000원에 매수` 또는 `일반에서 삼성 100만원어치 매수`."
## 카드 수정 / 취소 (활성 카드 있을 때)
활성 카드(미만료·미소비)가 있을 때 관리자님이 일부 항목만 바꾸길 원하면 `amend` 호출.
- **수정 가능 필드**: `qty`, `price`, `order_type`, `budget`, `routing_force`, `symbol`(+`symbol_name`), `account`. 한 번에 여러 개 가능.
- **수정 불가**: `side` (매수↔매도) — 의미가 정반대라 새 카드로 발행. "방향 바꿔" 류 입력은 "방향(매수/매도)은 amend 불가합니다. /orders_cancel 후 새로 발행해 주세요." 안내.
- **종목 변경 시 symbol_name 동반 필수**: 자연어 종목명 → `kiwoom_client.resolve_stock_code` 로 6자리 코드 변환 후 코드+이름 둘 다 명시.
- **계좌 변경 시 PIN 길이 자동 재조정**: 본인↔가희 전환 시 새 PIN 은 본인 4자리/가희 8자리로 발급.
- **PIN 전체 재발급**: amend 성공 시 새 PIN 다시 발송 + 만료 120초 리셋. card_id 는 유지.
- **0 입력 = 취소**: `qty`/`price`/`budget` 중 하나라도 0 이면 cancel 위임. 0 + 다른 amend 값 동시 입력은 `AMBIGUOUS_AMEND` 거부.
- **가드 재실행**: 잔고·보유·가격대·시간대·NXT·상태 12개 가드 전부 재검증. 통과 시에만 갱신.
### 자연어 트리거 예시
| 입력 | CLI |
|---|---|
| "수량 20주로 바꿔줘" / "20주로" | `amend --qty 20 --send` |
| "가격 76000원으로" | `amend --price 76000 --send` |
| "20주 76000원으로" | `amend --qty 20 --price 76000 --send` |
| "시장가로 바꿔" | `amend --order-type MARKET --send` |
| "예산 200만원으로" | `amend --budget 2000000 --send` |
| "지금 바로로 바꿔" | `amend --order-type AGGRESSIVE_LIMIT --send` |
| "ISA로 바꿔" / "가희 일반으로" | `amend --account ISA --send` (계좌만) |
| "카카오로 바꿔" / "035720으로" | `amend --symbol 035720 --symbol-name 카카오 --send` |
| "ISA에서 카카오로" | `amend --account ISA --symbol 035720 --symbol-name 카카오 --send` |
| "0주로" / "취소" / "그만" | `amend --qty 0 --send` (또는 `cancel --send`) |
활성 카드가 없으면 `NO_ACTIVE_CARD` 거부 응답을 그대로 echo.
**예 7 — 수량 수정**
- 입력: "수량 20주로 바꿔줘"
- 명령:
```bash
python3 -m orders.handler amend --qty 20 --send
```
- 응답: `[계좌명] [종목명] [매수/매도] 카드 발급완료`
**예 8 — 가격 + 주문방식 동시 수정**
- 입력: "85000원 지정가로"
- 명령:
```bash
python3 -m orders.handler amend --order-type LIMIT --price 85000 --send
```
**예 9 — 0 입력으로 취소**
- 입력: "0주로" / "취소해줘" / "그만"
- 명령:
```bash
python3 -m orders.handler amend --qty 0 --send
# 또는
python3 -m orders.handler cancel --send
```
**예 10 — 계좌만 변경 (본인↔가희 전환 시 PIN 길이 자동)**
- 입력: "가희 ISA로 바꿔" (활성 카드 본인 일반)
- 명령:
```bash
python3 -m orders.handler amend --account 가희_ISA --send
```
- 응답: PIN 8자리 영숫자로 재발급된 카드 발행.
**예 11 — 종목 변경 (코드+이름 동반 필수)**
- 입력: "카카오로 바꿔서 매수" (활성 카드 005930 삼성전자)
- 처리: `kiwoom_client.resolve_stock_code 카카오` → `035720`
- 명령:
```bash
python3 -m orders.handler amend --symbol 035720 --symbol-name 카카오 --send
```
**예 12 — 종목·계좌 동시 변경**
- 입력: "ISA에서 카카오 10주로 바꿔"
- 명령:
```bash
python3 -m orders.handler amend --account ISA --symbol 035720 --symbol-name 카카오 --qty 10 --send
```
**예 13 — 방향 변경 시도 → 거부 안내**
- 입력: "매도로 바꿔" (활성 카드는 BUY)
- 응답: "방향(매수/매도)은 amend 불가합니다. `/orders_cancel` 후 새로 발행해 주세요."
## 되묻기 응답 처리 규칙
직전 턴에서 레이가 넘버링 옵션을 제시하고, 사용자가 짧은 숫자/라벨로만 회신한 경우:
- 1자리 숫자 (`1`~`4`) — 계좌 선택. 직전 매매 의도(종목·수량·가격·side)와 합쳐 propose 호출.
- 라벨 직접 입력(`일반`, `ISA`, `가희_일반`, `가희_ISA`) — 동일하게 처리.
- **PIN 과 혼동 주의** — PIN 은 본인 4자리 / 가희 8자리. 1~2자리 숫자는 PIN 이 아니다. 활성 카드가 있더라도 1~2자리 숫자는 계좌 선택으로 해석.
- 직전 턴이 매매 되묻기가 아니면 숫자만 회신은 무시하고 일반 응답.
**예 5 — 사이드카 ON**
- 입력: "삼성 5주 75000원에 사줘"
- 명령 결과: `{"ok": false, "message": "🚫 매매 비활성화 상태..."}`
- 응답: 그대로 echo. "🚫 매매 비활성화 상태입니다. /orders_on 으로 재개하세요."
## 거부되는 요청 (이 스킬 호출하지 말 것)
- "워치리스트 도달했으니 자동 매수해" → 자율 매매 금지. 카드 발행 X.
- "내일 자동으로 사줘" → 예약 매매 X. 시점에 관리자님이 직접 명령.
- "이거 살까 말까?" → 의견 질문이지 매매 지시 아님. 카드 발행 X.
## 가드 (자동 적용 — 이 스킬 호출 후 거부 메시지가 오면 그대로 echo)
`orders.handler.propose_trade` 가 다음을 자동 검증:
- 계좌 화이트리스트
- 거래시간 + NXT 매트릭스 (NXT 시간대에 NXT 미지원 종목 → 거부)
- NXT 시간대·KRX 단일가 시장가 → 거부
- ±30% 상한가/하한가
- 거래정지·VI·휴장일
- 잔고/보유수량 사전조회
- 거래 간 60초 / 같은 종목 3분 딜레이 (실주문 접수 이후만 카운트 — PIN 오타·취소·키움 거절은 면제)
- 같은 종목 3분 — 키움 진실 소스(kt00007) 추가 검증. 우리 ledger 모르는 매매(NETWORK 사각·사용자 키움앱 매매)도 차단. 키움 조회 실패 시 보수적 차단
- 사이드카 ON / LLM 환경변수 차단
- **예산 환산** (budget 입력 시 qty 결정 직전): `BUDGET_INVALID` (0/음수), `NO_ORDERBOOK` (호가창 조회 실패), `BUDGET_TOO_SMALL` (1주 가격 > 예산)
거부 시 `⛔ 거부 [코드]: 메시지` 형태. 그대로 관리자님께 전달.
## 키움 접수 후 미체결 주문 정정·취소 (kt10002 / kt10003)
`amend` / `cancel` 은 **활성 카드(PIN 미입력 단계)** 만 처리한다. PIN 통과 후 키움에 접수된 미체결 주문은 별도 진입점이 처리한다 — `cancel-order` / `modify-order`.
### 절대 원칙
- **PIN 게이트 없음** — 취소는 안전 방향이라 즉시 실행. 정정도 동일 정책 (관리자님 결정).
- **자율 트리거 금지** — 워치리스트·시장 이벤트로 자동 취소/정정 발화 X.
- **`--live` 없으면 dry-run** — 안전 기본값. 실주문은 `--live` 명시 필요.
- **시장가 미체결 정정 불가** — 키움 명세상 가격 0 불가. 취소 후 신규 발주로 안내.
### 자연어 트리거
| 입력 | CLI |
|---|---|
| "미체결 보여줘" / "취소할 주문 뭐 있어" | `open-orders --send` (혹은 결과를 텍스트로 echo) |
| "미체결 취소" (1건만 있을 때) | `cancel-order --live --send` |
| "ord_no 12345 취소" | `cancel-order --ord-no 12345 --live --send` |
| "ISA 미체결 다 취소" (ISA에 1건) | `cancel-order --account ISA --live --send` |
| "ord_no 12345 가격 76000으로 정정" | `modify-order --ord-no 12345 --price 76000 --live --send` |
| "ord_no 12345 수량 3주로 정정" | `modify-order --ord-no 12345 --qty 3 --live --send` |
### 자동 대상 추출
- `ord_no` 명시 — 그대로 사용. account 도 같이 주면 빠르고, 안 줘도 4계좌에서 매칭.
- `ord_no` 미명시 + 미체결 1건 — 자동 선택.
- `ord_no` 미명시 + 미체결 2건 이상 — `AMBIGUOUS` 거부 + 리스트 echo. 관리자님께 ord_no 선택 요청.
- 미체결 0건 — `NO_OPEN_ORDERS` 거부.
### 응답 형식
- 취소 성공: `✅ 취소 접수 · 원주문 ord_no=X → 취소 ord_no=Y · [계좌] 종목 매수/매도 N주 (KRX)`
- 이후 broker 확정 시 후속 알림: `✅ 매수/매도 취소 확인: 종목 N주 취소됨\n원주문: X / 취소주문: Y` (fill_watcher 데몬이 ka10075 폴링)
- 30분 미확인 시: `⏱️ 매수/매도 취소 30분째 미확인 ...`
- 정정 성공: `✏️ 정정 접수 · 원주문 ord_no=X → 정정 ord_no=Y · [계좌] 종목 매수/매도 5주 @ 75,000원 → 3주 @ 76,000원 (KRX)`
- dry-run: `🧪 [DRY-RUN] ...`
- 키움 거절: `⛔ 취소/정정 실패 [BROKER_REJECT] · ord_no=X ...\n사유: ...`
### 예시
**예 A — 미체결 1건 즉시 취소**
- 입력: "미체결 취소해줘"
- 명령:
```bash
python3 -m orders.handler cancel-order --live --send
```
- 응답: `✅ 취소 접수 ...` (단일 한 줄)
**예 B — ord_no 명시 정정 (가격만)**
- 입력: "0001234 가격 76000으로 정정"
- 명령:
```bash
python3 -m orders.handler modify-order --ord-no 0001234 --price 76000 --live --send
```
**예 C — 다수 미체결 → 모호**
- 입력: "미체결 취소"
- 명령:
```bash
python3 -m orders.handler cancel-order --live --send
```
- 응답:
```
대상 ord_no 를 명시해 주세요:
ord_no=0001234 · [일반] 삼성전자 (005930) 매수 5주 @ 75,000원 · 미체결 5주 ...
ord_no=0005678 · [ISA] 카카오 (035720) 매도 10주 @ 50,000원 · 미체결 10주 ...
```
- 관리자님이 ord_no 회신 시 그 값으로 재호출.
**예 D — 시장가 정정 시도 → 거부 안내**
- 입력: "시장가 미체결 가격 76000으로 정정" (원주문이 시장가)
- 응답: `⛔ 시장가 미체결은 정정 불가 (가격 0). 취소 후 신규 발주하세요.`
@@ -0,0 +1,49 @@
---
name: stock-agent
description: 주식 포트폴리오 리포트를 담당하는 에이전트. 키움 REST API(조회 전용)로 4계좌(본인 일반·ISA + 가희 일반·ISA) 보유종목·예수금·손익을 실시간 조회해 owner 그룹별 블록 형태로 이메일 리포트 발송. 평일 20:10 launchd 자동 실행.
---
# stock-agent
주식 포트폴리오 일일 리포트를 담당합니다. 2026-04-24부터 **키움 REST API 기반**으로 전환 — `portfolio.json` 및 네이버 시세 의존 제거, iMessage 체결 파싱(`portfolio-update.py`)도 폐기되었습니다.
## 데이터 소스
- `kt00018` 계좌평가잔고내역: 종목별 수량·평단·현재가·평가액·손익·수익률·당일매매수량 (ground truth)
- `kt00001` 예수금상세현황: 예수금·주문가능금액·대용금
- 4계좌(본인 **일반**·**ISA** + **가희_일반**·**가희_ISA**) 각각 별도 AppKey로 조회 — 토큰이 계좌에 바인딩되어 자동 분기
- owner 그룹은 라벨 prefix로 결정: `_` 없는 라벨은 본인, `가희_` prefix는 가희
## Commands
```bash
python3 scripts/stock_portfolio_report.py run # owner 블록 뷰 출력 (기본)
python3 scripts/stock_portfolio_report.py run --by-account # owner 블록 안에서 계좌별 분리 뷰
python3 scripts/stock_portfolio_report.py send # owner 블록 뷰 이메일 발송 (텔레그램 요약 없음)
python3 scripts/stock_portfolio_report.py send --by-account # 분리 뷰 이메일 발송 (텔레그램 요약 없음)
```
- 기본 표시는 owner 그룹별 블록(본인 / 가희) — 그룹 내에서만 같은 종목 가중평단 머지. 본인↔가희 자금은 절대 합산하지 않음
- `--by-account`: owner 블록 안에서 추가로 계좌별(4계좌) 섹션까지 분리
- 당일 매매 있는 종목은 `★ 오늘 매매 발생` 표시 (tdy_buyq/tdy_sellq 기반)
- 일일 리포트는 이메일만 발송합니다. 텔레그램 요약 발송은 2026-05-14 관리자님 요청으로 비활성화했습니다.
- **전날대비**는 owner별 **순자산(평가금액 + 예수금)** 기준으로 계산. 평가금액만으로는 매도 시 평가→예수금 이동분이 손실처럼 표시되어 부정확
## Workflow
1. `kiwoom_client.get_positions_all()` → 4계좌 보유종목 수집
2. owner 그룹별로 분리 → 그룹 내 같은 종목 가중평단 머지 (`--by-account`면 계좌까지 유지)
3. 예수금/주문가능/대용금 계좌별 표시 (owner 블록 안)
4. `state/portfolio_daily_snapshot.json`에 v4 스키마(`{owner: {holdings: {stock: {...}}, deposit: int, trade_journal: [...], realized_pl_total: int, realized_fees_total: int}}`)로 오늘 날짜 스냅샷 저장. legacy v1(flat)·v2(owner→stock)는 자동 승격하되 `deposit=None` 처리되어 해당 일자에 대한 순자산 비교는 불가. v3 스냅샷은 매매일지 키가 없을 뿐 그대로 호환
5. `send` 모드면 gog gmail로 관리자님 메일만 발송 (레이 봇 텔레그램 요약 없음)
## Files
- Script: `scripts/stock_portfolio_report.py` (심볼릭 링크: `skills/stock-agent/scripts/stock_portfolio_report.py`)
- Snapshot: `state/portfolio_daily_snapshot.json` (일자별 v4 스냅샷 — `{owner: {holdings: {stock: {qty,avg_price,price,eval_value,profit}}, deposit: int, trade_journal: [{code,name,accounts,buy_qty,buy_avg,buy_amt,sell_qty,sell_avg,sell_amt,realized_pl,fees_tax,realized_profit_rate}], realized_pl_total: int, realized_fees_total: int}}`. 전날대비 순자산 비교 + 월별 실현손익/회전율/수수료 통계용. legacy v1/v2/v3 자동 호환)
- Portfolio memo: `memory/portfolio.json` (v2 스키마 — 참고용. 실제 리포트 숫자는 REST 실시간 조회)
- 의존 스킬: `kiwoom-rest` (클라이언트 및 TR 매핑)
## 매매 절대 원칙
이 스킬은 조회·리포트만 수행합니다. 매수·매도 트리거로 이어지는 어떤 자동화도 포함하지 않으며, 매매 제안이 필요한 경우 텔레그램 알림까지가 한계입니다 (kiwoom-rest 스킬 참고).
@@ -0,0 +1 @@
/Users/snowoyh/.openclaw/agents/stock/workspace/scripts/stock_portfolio_report.py