# orders 모듈 — 매매 절차 원칙으로 전환

2026-05-06 코디 작업. 기존 "매매 절대 원칙(주문 함수 절대 부재)"을 풀고, **사람의 PIN echo를 마지막 게이트로 두는 매매 절차 원칙**으로 정책 전환.

## 핵심 원칙 (변경 없음)

- **레이는 매매를 결정하지 않는다.** 결정권은 항상 관리자님 PIN echo.
- **자율 매매 금지** — 워치리스트 도달·시장 이벤트·뉴스로 자동 트리거 X. 명시적 매매 지시("사줘"/"팔아줘"/"매수"/"매도")가 있을 때만.
- **레이의 역할은 페이로드 추출** — 자연어 → 종목코드·계좌·수량·가격을 dict로 정리해서 `orders.handler.propose_and_send` 진입점에 전달.
- **카드+PIN 분리 발송** — 카드 메시지 + PIN 메시지 두 개를 텔레그램에 분리 발송. PIN 메시지는 단독(메타 X).
- **PIN 변형 금지** — 관리자님 PIN을 그대로 echo. 변형·생성·시도 절대 X.
- **사이드카 ON 상태에선 거부** — `state/orders_disabled` 파일 존재 시 모든 진입점 차단.

## 패키지 구성

위치: `agents/stock/workspace/scripts/orders/`

| 모듈 | 역할 |
|---|---|
| `__init__.py` | 매매 절차 원칙 docstring (정책 명문화) |
| `limits.json` | 모든 한도·딜레이·시간대·PIN·라우팅 단일 진실 공급원 |
| `sidecar.py` | kill switch (`state/orders_disabled` 파일 검사) |
| `pin.py` | PIN 발급·검증·만료. **파일 기반(`state/active_card.json` + flock)** — cross-process 활성 카드 공유 |
| `ledger.py` | append-only 주문 로그 (`state/order_log.jsonl`) + 멱등성 해시 |
| `guards.py` | 12종 검증 (계좌·시간대·NXT·딜레이·±30%·잔고·시장가). 순수 함수 |
| `datasource.py` | 키움 → market_data dict 어댑터 |
| `kiwoom_order.py` | kt10000(매수)/kt10001(매도) 호출. dry-run default |
| `card.py` | 텔레그램 카드 포맷터 — 매수/매도·계좌·종목·가격 4개 *bold* 하이라이트 |
| `handler.py` | 흐름 + CLI + send_telegram 통합 (propose_and_send / submit_with_pin_and_send) |
| `expiry_watcher.py` | launchd 만료 정리 진입점 (등록은 안 함) |
| `tests/test_guards.py` | 단위테스트 81건 |

## 한도 / 정책 (관리자님 결정)

- **매매 허용 계좌**: 본인 4계좌 (가희 포함)
- **1회 / 1일 / 잔고% 한도**: 모두 없음
- **종목**: 자유 (코스피·코스닥·ETF·ETN)
- **시장가**: 허용. 단 NXT 시간대(08:00–09:00, 15:30–20:00)와 KRX 단일가(15:20–15:30)에선 거부 (지정가만 가능)
- **자연어**: "시장가" → MARKET, "지금 바로/즉시/빨리/당장" → 최우선호가+1틱 지정가
- **가격 가드**: 거래소 ±30% 상한가/하한가 초과 지정가 거부
- **거래시간**: 08:00–20:00 (NXT 포함). 시간대×NXT eligible 매트릭스
- **라우팅 default**: SOR (`_AL`). "넥스트레이드/NXT" → `_NX`, "정규장/KRX" → KRX
- **거래 간 딜레이**: 60초 (마지막 카드 종결 시점부터)
- **동일 종목 딜레이**: 600초 (10분, 마지막 체결 시점부터)
- **PIN**: 본인 4자리 숫자, 가희 8자리 영숫자(혼동글자 0/O/o/1/l/I 제외 55자)
- **PIN 만료**: 60초, 1회용, 1회 시도 (틀리면 카드 무효 + 재발행 필요)
- **카드+PIN 분리 발송**: 메시지 A(카드, Markdown) + 메시지 B(PIN 단독, plain)
- **계좌 default 없음** — 미명시 시 넘버링 되묻기 (1) 일반 2) ISA 3) 가희_일반 4) 가희_ISA)

## 호출 진입점

### CLI (수동 검증·디버깅)
```bash
cd ~/.openclaw/agents/stock/workspace/scripts
python3 -m orders.sidecar status
python3 -m orders.sidecar enable      # 매매 풀기
python3 -m orders.sidecar disable     # 잠그기
python3 -m orders.handler propose <account> BUY <symbol> <name> <qty> LIMIT <price> [routing] [--send]
python3 -m orders.handler pin <PIN> [--live] [--send]
python3 -m orders.handler cancel
python3 -m orders.handler status
python3 -m orders.handler cmd /orders_off
python3 -m orders.handler cmd /orders_on
```

### skill (텔레그램 자연어)
- `skills/order-trading/SKILL.md` — 자연어 매수/매도 → propose_and_send
- `skills/order-controls/SKILL.md` — PIN echo + /orders_off /orders_on /cancel /orders_status

## 첫 검증 대기

2026-05-06 코디 작업 종료 시점 상태:
- 사이드카 **default ON** (`state/orders_disabled` 파일 존재) — 모든 매매 진입점 거부
- launchd plist `ai.openclaw.stock.order-expiry.plist` 작성됐지만 **등록 안 함** (10초 데몬 비용 비대칭). 만료 알림은 다음 매매 액션 시점에 sweep으로 사후 처리

관리자님 첫 검증 절차 (예정):
1. KRX 정규장 시간 확보
2. 사이드카 OFF: `python3 -m orders.sidecar enable`
3. 텔레그램에 자연어 매수 (권장: 본인 일반에서 KODEX 200 ETF 1주 시장가 — 변동성 ↓ 호가 ↑)
4. 카드 검토 → PIN echo → 체결 알림 확인
5. 잘 되면 한도 자유롭게 사용

## 검증 후 보강 예정

| 항목 | 현재 | 보강 트리거 |
|---|---|---|
| ~~ka10004 호가창 응답 필드명~~ | ✅ 2026-05-07 PDF 명세 기반 정확화 (`sel_fpr_bid` 1호가 + `sel_{N}th_pre_bid` 2~10호가) | 완료 |
| ~~`_nxt_eligible`~~ | ✅ 2026-05-07 ka10099 `nxtEnable` 캐시 활용. `lookup_stock_meta(code)` 신규. 캐시 미스/구 스키마 → 보수적 True. 4264 종목 갱신 후 NXT 가드 정확화 (3634/4264이 미상장). 회귀 테스트 5건(`test_nxt_eligible.py`) | 완료 |
| 거래정지(halt) 플래그 | False stub | **REST 단독 플래그 없음** (실시간 websocket `1h` 만 존재). 사후 broker reject가 안전판. ka10099 `state`에 "거래정지" 키워드 매핑은 후속 보강 안건 |
| 체결(filled) 폴링 | 미구현 (접수까지만 ledger) | 별도 체결조회 TR 추가 (kt00007 활용 가능) |
| ~~`ord_seq_no`/`rt_cd` fallback~~ | ✅ 2026-05-07 명세에 없는 키 제거. `response_summary`도 `return_code`+`return_msg` 정확화. 회귀 테스트 6건(`test_kiwoom_order.py`) 신규 | 완료 |
| ~~위험 종목 카드 경고~~ | ✅ 2026-05-07 등급별 차등 정책 적용. `guards.evaluate_stock_state` 신규. 거부: orderWarning ∈ {2 정리매매, 4 투자위험} OR state에 '거래정지'·'정리매매'. 경고: orderWarning ∈ {1 ETF주의, 3 단기과열, 5 투자경과} OR state에 '관리종목'. 회귀 14건(`test_stock_state.py`). 캐시 기준 거부 대상 약 110+종목, 경고 대상 약 175종목 | 완료 |
| 자연어 파싱 정확도 | LLM 1차 검증 후 | 자주 틀리는 패턴 발견 시 SKILL.md 보강 |
| ~~list 응답 페이지네이션~~ | ✅ 2026-05-07 `_call_paginated(label, tr_id, body, list_field)` 헬퍼 신규. get_positions(kt00018)·get_trade_journal(ka10170)·get_order_executions(kt00007) 페이징 처리. 응답 헤더 cont-yn=Y + next-key 자동 후속 호출, list 누적, max_pages=50 safety cap. 현재 종목 30개 환경에선 cont-yn=N으로 1페이지에 끝나지만 미래 안전판. 회귀 8건(`test_pagination.py`) | 완료 |

## 키움 TR 검증 결과

**2026-05-07 PDF 공식 명세 전수 대조 완료** (`~/.openclaw/docs/키움 REST API 문서.pdf` — 카탈로그는 `~/.openclaw/docs/README.md`).

| TR | endpoint | 상태 |
|---|---|---|
| kt10000 (매수) | `/api/dostk/ordr` | ✅ body 9필드 일치 (2026-05-06 검증) |
| kt10001 (매도) | `/api/dostk/ordr` | ✅ body 9필드 일치, 첫 실매도 통과 (2026-05-07 KODEX 은선물 1주) |
| kt00001 (예수금) | `/api/dostk/acnt` | ✅ |
| kt00004 (계좌평가) | `/api/dostk/acnt` | ✅ |
| kt00007 (체결내역) | `/api/dostk/acnt` | ✅ |
| kt00018 (잔고내역) | `/api/dostk/acnt` | ✅ |
| ka10001 (주식기본정보) | `/api/dostk/stkinfo` | ✅ + 2026-05-07 상한가/하한가 필드 수정 (`upl_pric`/`lst_pric`) |
| ka10004 (호가) | `/api/dostk/mrkcond` | ✅ 2026-05-07 endpoint·필드 모두 수정 |
| ka10099 (종목리스트) | `/api/dostk/stkinfo` | ✅ |
| ka10170 (당일매매일지) | `/api/dostk/acnt` | ✅ (`sel_avg_pric` 키움 자체 오타 정확 매핑) |
| ka10075 (미체결) | `/api/dostk/acnt` | ✅ 2026-05-13 신규. 활성 미체결만, 정정/취소 대상 추출용 (`kiwoom_client.get_open_orders`) |
| kt10002 (정정) | `/api/dostk/ordr` | ✅ 2026-05-13 신규. body 6필드 (`dmst_stex_tp`, `orig_ord_no`, `stk_cd`, `mdfy_qty`, `mdfy_uv`, `mdfy_cond_uv`) |
| kt10003 (취소) | `/api/dostk/ordr` | ✅ 2026-05-13 신규. body 4필드 (`dmst_stex_tp`, `orig_ord_no`, `stk_cd`, `cncl_qty`). `cncl_qty='0'` 잔량 전부 취소 |

**kt10000/kt10001 body 명세:** `dmst_stex_tp`(SOR/NXT/KRX), `stk_cd`(종목코드), `ord_qty`(str), `ord_uv`(가격 또는 빈 문자열), `trde_tp`('0' 보통 / '3' 시장가), `cond_uv`(미사용)

## 2026-05-07 budget(예산) 기반 매매 추가

자연어 "삼성전자 100만원어치 매수" 처리. 키움 API는 금액 입력 안 받아서 우리가 환산.

- **환산 규칙:** 매수=매도1호가, 매도=매수1호가, floor 나눗셈, 슬리피지 마진 0%
- **소수점 매매 안 함** (정수 주식만, 잔액은 카드에 표시)
- **거부:** `BUDGET_TOO_SMALL` (1주 가격 > 예산), `BUDGET_INVALID` (0/음수), `NO_ORDERBOOK` (호가 조회 실패)
- **CLI:** `python3 -m orders.handler propose <account> BUY <symbol> <name> - MARKET --budget <won> --send` (qty 자리는 `-`)
- **키움 발주:** 환산된 정수 qty + 시장가(`trde_tp=3`)로 그대로 진행. 환산용 가격은 우리 내부 계산용일 뿐 키움에 안 감.
- **회귀 테스트:** `tests/test_guards.py` 9건 + `tests/test_handler_budget.py` 8건 추가, 총 111건 통과

`skills/order-trading/SKILL.md` 에 `budget` 페이로드 추출 규칙·예시 4개 등록.
- 응답 `ord_no`: 주문번호, `return_code`: 0 성공

## 2026-05-13 키움 접수 후 미체결 정정/취소 추가

신규 매매 카드(`amend`/`cancel`)는 **PIN 미입력 단계 활성 카드**만 처리. PIN 통과 후 키움에 접수된 미체결 주문은 별도 진입점.

- **신규 TR:** ka10075(미체결 조회), kt10002(정정), kt10003(취소)
- **kiwoom_client.py (read-only):** `get_open_orders(label)`, `get_open_orders_all()` — 활성 미체결 행 정규화 반환
- **kiwoom_order.py:** `cancel_order()`, `modify_order()` — submit() 패턴 그대로. dry-run default, ledger 기록
- **handler.py:** `cancel_open_order()`, `modify_open_order()` — auto-resolve(ord_no 미명시 + 1건이면 자동, 다수면 AMBIGUOUS 거부 + 리스트)
- **PIN 게이트 없음** — 관리자님 결정 (취소는 안전 방향). 단 `--live` 명시 없으면 dry-run
- **신규 ledger 이벤트:** `cancel_submitted`, `cancel_rejected`, `modify_submitted`, `modify_rejected`
- **CLI 진입점:**
  ```bash
  python3 -m orders.handler open-orders [--account A]
  python3 -m orders.handler cancel-order [--ord-no N] [--account A] --live --send
  python3 -m orders.handler modify-order [--ord-no N] [--account A] [--qty Q] [--price P] --live --send
  ```
- **시장가 미체결 정정 불가** — 키움 mdfy_uv 0 불가. 취소 후 신규 발주 안내
- **routing_suffix 보존** — 원주문 ka10075 응답의 stex_tp(0/1/2) → `_AL`/``/`_NX` 매핑해서 정정/취소 호출에 그대로 전달 (거래소 일치 필수)
- **검증:** dry-run 10건 회귀 + 실 키움 ka10075 호출 확인 (2026-05-13)
