- 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
9.8 KiB
9.8 KiB
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 모두)은 진행하지 않았다 — 자격증명 필요.
사용법
# .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.md1.1에서 지적한 것과 같은 패턴), NH 버전은example_nh.py와 동일하게APP_KEY/APP_SECRET환경변수로만 주입한다. - 구독 채널:
recv_price("005930")(체결가, WStr_cd="oc")와recv_orderbook("005930")(호가, WStr_cd="ob")를 구독한다. recv_index는 호출하지 않음: NH krstock API에는 지수 실시간 채널이 없어Nh.recv_index가 stub이기 때문이다(호출해도 "not supported" 로그만 남기고 아무것도 구독하지 않는다) — 왜 빠졌는지 예제 코드에 주석으로 남겼다.
향후 과제
.mst종목마스터 파서 구현 (get_stock_list) — 구조체 정의는https://www.nhplug.com/instruments/m_new_stock.h에 공개되어 있음.- 정정주문(
modify)·예약주문(reservedOrder/reservedCancel)·잔고 외 조회 TR을 필요 시BaseProvider확장 없이Nh의 부가 메서드로 노출(다른 브로커의approval(),get_condition_list()류 관례를 따름). - 해외주식(
gbstock) 등 다른 자산군이 필요해지면 별도 브로커 클래스가 아니라Nh내 부가 메서드로 확장할지, 새 파사드로 분리할지 결정 필요 — 현재BaseProvider는 국내주식 중심 인터페이스라 해외/파생 자산군의 필드(외화, 증거금 등)를 그대로 담기 어렵다.