# 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`는 국내주식 중심 인터페이스라 해외/파생 자산군의 필드(외화, 증거금 등)를 그대로 담기 어렵다.