Add NH(나무) broker integration and fix balance/holds parsing

- finestock/nh/: Nh/NhV 브로커 클래스 추가 (시세/주문/잔고/실시간 WS)
- api_factory.py, path.py: APIProvider.NH/NHV 등록, 도메인/엔드포인트 매핑
- kis.py: get_holds/get_ohlcv_min/get_index_min/get_stock_list 스텁 추가,
  oauth() Content-Type 헤더 수정
- get_balance()의 실전 디버깅으로 드러난 버그 수정:
  - Hold.total(매입금액)이 존재하지 않는 byn_amt 필드를 참조해 항상 0이던 것을
    eal_amt - eal_pls_amt로 계산하도록 수정
  - rsp_cd를 "00000" 단일 값으로만 성공 판정해 정상 응답('00218' 연속조회 중,
    '00166' 마지막 페이지 등)을 실패로 오판하던 것을 Output_0 존재 여부로 판정
  - 응답 헤더의 cts/cts_flag로 연속조회를 재귀 처리해 10건 넘는 보유종목도
    전부 합쳐서 반환하도록 구현
- doc/, tests/, example_*.py, setup.py, requirements.txt, CLAUDE.md 등 추가
- README.md에 .env 환경변수 설정 가이드 추가

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01225Lu4Fc2UpMz6QixEcNT8
This commit is contained in:
2026-08-31 14:35:30 +09:00
co-authored by Claude Sonnet 5
parent a4dceccae1
commit eaf18362f4
29 changed files with 2130 additions and 7 deletions
+93
View File
@@ -0,0 +1,93 @@
# NH투자증권(나무/Namuh) OpenAPI 연동
`finestock`에 다섯 번째 브로커로 NH투자증권 나무(Namuh) Open API를 추가한 작업 기록이다. 스펙은 포털(`https://www.nhplug.com`)이 AI/에이전트용으로 제공하는 `llms-full.txt`와 국내주식(`krstock`) 카테고리의 정본 `openapi.json`을 직접 내려받아 필드 단위로 확인하며 반영했다.
- 스펙 소스: `https://www.nhplug.com/llms-full.txt`, `https://www.nhplug.com/openapi-docs/{common,krstock}/openapi.json`
- 대상 자산군: 국내주식(krstock)만 구현. 해외주식/국내·해외파생/장내채권/금현물은 이번 작업 범위 밖.
## 추가/변경 파일
| 파일 | 내용 |
|---|---|
| `finestock/nh/nh.py` | `Nh(API)``BaseProvider`(6개 인터페이스 합성) 전체 구현 |
| `finestock/nh/nh_v.py` | `NhV(Nh)` — 모의투자. 도메인만 다르고 메서드 오버라이드 없음 |
| `finestock/nh/__init__.py` | `Nh`/`NhV` 재노출 |
| `finestock/path.py` | `_NH_`/`_NH_V_` 엔드포인트 딕셔너리, `_API_PATH_``"Nh"`/`"NhV"` 등록 |
| `finestock/api_factory.py` | `APIProvider.NH`/`APIProvider.NHV` 추가, `APIFactory.create_api` 분기 추가 |
| `example_nh.py` | 다른 브로커 예제(`example_kis.py` 등)와 동일한 패턴의 동기 REST 사용 예제 |
| `example_async_nh.py` | `example_async_kiwoom.py`와 동일한 패턴의 비동기 실시간(WebSocket) 사용 예제 |
| `CLAUDE.md` | 아키텍처 문서의 "Class hierarchy per broker" 절에 NH 항목 추가 |
## NH API의 구조적 특징 (다른 브로커와 다른 점)
- **봉투(envelope) 통일**: 모든 REST TR이 `POST` + `{"Input_0": {...}}` 요청 / `{rsp_cd, rsp_msg, Output_0[, Output_1, Output_2], message}` 응답이라는 하나의 규격을 따른다. LS(`tr_cd` + `{TR}InBlock`)나 KIS(`tr_id` 헤더 + TR별 파라미터명)처럼 TR마다 요청/응답 스키마 형태 자체가 달라지지 않는다.
- **인증 헤더가 3종류**: `Authorization: Bearer {token}` + `x-client-id` + `x-client-secret`. 베이스 클래스(`API.set_oauth_info`)는 `appkey`/`appsecret` 헤더를 세팅하므로, `Nh.set_oauth_info`를 오버라이드해 `x-client-id`/`x-client-secret`를 채운다.
- **접근토큰발급은 항상 운영 전용**: 모의투자(`moapi.nhplug.com`)는 대부분의 TR을 제공하지만 `POST /oauth2/token`만은 제공하지 않는다. 발급받은 토큰은 운영/모의 양쪽에 그대로 쓴다. 이를 위해 `path.py``OAUTH_DOMAIN`을 별도로 두어 `Nh`/`NhV` 모두 같은 값(운영 도메인)을 갖게 하고, `Nh.oauth()``self.DOMAIN`이 아니라 `self.OAUTH_DOMAIN`으로 요청한다 — `NhV`에서 `DOMAIN`만 모의투자로 바뀌어도 `oauth()`는 영향받지 않는다.
- **계좌번호가 단일 필드**: KIS의 `CANO`+`ACNT_PRDT_CD`처럼 계좌를 앞자리/뒤 2자리로 나누지 않고, `/n2/acctinfo` 응답의 `acct_no`(11자리) 하나를 그대로 각 TR의 `act_no`에 넣는다. `set_account_info(account_num, account_num_sub)` 시그니처는 유지하되 `account_num_sub`는 보통 비워 쓴다(`_act_no()` 헬퍼가 있으면 이어붙이고, 없으면 `account_num`만 사용).
- **연속조회 방식이 다름**: LS/Kiwoom은 `cts_date`/`cts_time`, KIS는 `CTX_AREA_FK100`/`NK100`로 페이지네이션하지만, 국내주식 기간별시세(`period`)는 연속조회 키 자체가 없다. 대신 `array_cnt`로 한 번에 받을 건수를 지정하고, `edate` 기준으로 내려오는 배열을 클라이언트에서 날짜 범위로 잘라 쓴다.
## 인터페이스 → NH TR 매핑
| `BaseProvider` 메서드 | NH REST/WS | 비고 |
|---|---|---|
| `oauth()` | `POST /oauth2/token` | 항상 `OAUTH_DOMAIN`(운영) 고정 |
| `set_oauth_info()` | — | `x-client-id`/`x-client-secret` 헤더 세팅으로 오버라이드 |
| `get_price(code)` | `POST krstock/quote/v1/currentPrice` | |
| `get_orderbook(code)` | `POST krstock/quote/v1/currentPrice` | 호가 전용 TR이 없어 현재가 응답의 `askp1..10`/`bidp1..10`/`askp_rsqn*`/`bidp_rsqn*` 재사용 |
| `get_ohlcv(code, frdate, todate)` | `POST krstock/quote/v1/period` (`gubun=1`, 일봉) | 연속조회 키 없음 → `array_cnt`로 받아 `frdate~todate`로 클라이언트 필터링 |
| `get_ohlcv_min(...)` | `POST krstock/quote/v1/period` (`gubun=5`, 분봉) | 동일 TR 재사용. `cts_date`/`cts_time`/`tr_cont_key` 인자는 NH가 분봉 연속조회를 지원하지 않아 받기만 하고 사용 안 함 |
| `do_order(code, buy_flag, price, qty)` | `POST krstock/order/v1/cashBuy` 또는 `cashSell` | `buy_flag`로 URL 자체를 분기(TR ID 문자열이 아니라 엔드포인트가 다름). `price==0`이면 시장가(`nmn_pr_tp_cd=05`), 아니면 지정가(`01`) |
| `do_order_cancel(order_num, code, qty)` | `POST krstock/order/v1/cancel` | `qty<=0`이면 전체취소(`all_pat_dit_cd=1`), 아니면 일부취소(`2`) |
| `get_balance()` | `POST krstock/inquiry/v1/balance` | `Output_0`=계좌 요약, `Output_1`=보유종목 배열 |
| `get_holds()` | (`get_balance()` 재사용) | |
| `recv_price(code, status)` | WS `tr_cd="oc"` (실시간체결가KRX) | |
| `recv_orderbook(code, status)` | WS `tr_cd="ob"` (실시간호가KRX) | |
## 구현하지 않은 부분 (정직하게 stub 처리)
- **`get_index` / `get_index_min` / `get_index_list`**: krstock(국내주식) API 자체에 지수 시세 TR이 없다. `[]`을 반환하며 안내 메시지만 출력한다.
- **`get_stock_list`**: 전종목 조회 REST API가 없고, 코드/종목명/업종 등 정적 정보는 `.mst` 바이너리 마스터 파일(`https://www.nhplug.com/instruments/m_new_stock.mst`, CP949·고정길이 레코드, 인증 불필요)로만 제공된다. 파서를 별도로 구현하지 않아 현재는 `[]` stub.
- **`recv_trade`**: NH의 체결통보 채널(`tr_cd="d2"`)은 종목코드가 아니라 계좌 `userid`를 구독키(`tr_key`)로 쓰는 계좌 단위 통보라, `recv_trade(code, status)` 시그니처로는 표현할 수 없다. stub 처리하고 사유를 주석/출력으로 남겼다.
- **주문 정정(`modify`), 예약주문, 잔고 외 조회 TR(실현손익/자산현황/권리 등)**: `path.py``ORDER_MODIFY` 경로는 등록해 뒀지만 `BaseProvider` 인터페이스에 해당 메서드가 없어 아직 브로커 메서드로 노출하지 않았다.
## 검증
- `finestock.create_api(APIProvider.NH)` / `NHV` 정상 인스턴스화 확인 — `BaseProvider``ABC`라 추상 메서드가 하나라도 안 채워지면 인스턴스화 자체가 `TypeError`로 실패하므로, 이 확인만으로 인터페이스 완전 구현이 보장된다.
- `requests.post``unittest.mock`으로 가로막고 다음을 검증하는 임시 테스트 스크립트를 작성해 전부 통과시켰다(저장소에는 포함하지 않음, 세션 스크래치패드에만 존재):
- `get_price`/`get_orderbook` 응답 파싱(호가 10단계 포함)
- `get_ohlcv``frdate`~`todate` 클라이언트 필터링
- `do_order`의 시장가/지정가 분기 및 매수/매도 URL 분기, `do_order_cancel`의 전체/일부 분기
- `get_balance`/`get_holds`의 계좌 요약·보유종목 파싱
- 실시간 호가(`ob`)/체결가(`oc`) 푸시 바디 파서(`_parse_orderbook`/`_parse_price`)
- `NhV.oauth()``DOMAIN`이 모의투자로 바뀐 상태에서도 `OAUTH_DOMAIN`(운영)으로 요청하는지
- 기존 `tests/` 스위트(`python -m unittest discover tests`) 회귀 없음 확인.
- `example_async_nh.py``ast.parse`로 문법 검증했고, `Nh` 인스턴스에 `connect`/`disconnect`/`recv_price`/`recv_orderbook`/`run`/`set_data_queue`가 모두 존재함을 확인했다.
- 실제 앱키를 이용한 라이브 호출(REST·WebSocket 모두)은 진행하지 않았다 — 자격증명 필요.
## 사용법
```bash
# .env 또는 환경변수에 APP_KEY / APP_SECRET / ACCOUNT_NUM 설정 후
# 동기 REST 예제 — 계좌 목록/시세/호가/잔고 조회
python example_nh.py
# 비동기 실시간(WebSocket) 예제 — 체결가/호가 구독
python example_async_nh.py
```
모의투자로 테스트하려면 `APIProvider.NH` 대신 `APIProvider.NHV`를 사용한다(단, `oauth()` 호출은 위에서 설명한 대로 두 경우 모두 운영 도메인으로 나간다).
### `example_async_nh.py`
`example_async_kiwoom.py`와 동일한 골격(큐 소비 태스크 + `connect → 구독 → run(30초 타임아웃) → disconnect`)을 따르되 다음을 NH에 맞게 반영했다.
- **자격증명 하드코딩 금지**: `example_async_kiwoom.py`는 앱키/시크릿을 코드에 직접 박아뒀지만(`doc/IMPROVEMENTS.md` 1.1에서 지적한 것과 같은 패턴), NH 버전은 `example_nh.py`와 동일하게 `APP_KEY`/`APP_SECRET` 환경변수로만 주입한다.
- **구독 채널**: `recv_price("005930")`(체결가, WS `tr_cd="oc"`)와 `recv_orderbook("005930")`(호가, WS `tr_cd="ob"`)를 구독한다.
- **`recv_index`는 호출하지 않음**: NH krstock API에는 지수 실시간 채널이 없어 `Nh.recv_index`가 stub이기 때문이다(호출해도 "not supported" 로그만 남기고 아무것도 구독하지 않는다) — 왜 빠졌는지 예제 코드에 주석으로 남겼다.
## 향후 과제
1. `.mst` 종목마스터 파서 구현 (`get_stock_list`) — 구조체 정의는 `https://www.nhplug.com/instruments/m_new_stock.h`에 공개되어 있음.
2. 정정주문(`modify`)·예약주문(`reservedOrder`/`reservedCancel`)·잔고 외 조회 TR을 필요 시 `BaseProvider` 확장 없이 `Nh`의 부가 메서드로 노출(다른 브로커의 `approval()`, `get_condition_list()`류 관례를 따름).
3. 해외주식(`gbstock`) 등 다른 자산군이 필요해지면 별도 브로커 클래스가 아니라 `Nh` 내 부가 메서드로 확장할지, 새 파사드로 분리할지 결정 필요 — 현재 `BaseProvider`는 국내주식 중심 인터페이스라 해외/파생 자산군의 필드(외화, 증거금 등)를 그대로 담기 어렵다.