Files
finestock/doc/NH_INTEGRATION.md
T
shinalokandClaude Sonnet 5 eaf18362f4 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
2026-08-31 14:35:30 +09:00

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.pyOAUTH_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.pyORDER_MODIFY 경로는 등록해 뒀지만 BaseProvider 인터페이스에 해당 메서드가 없어 아직 브로커 메서드로 노출하지 않았다.

검증

  • finestock.create_api(APIProvider.NH) / NHV 정상 인스턴스화 확인 — BaseProviderABC라 추상 메서드가 하나라도 안 채워지면 인스턴스화 자체가 TypeError로 실패하므로, 이 확인만으로 인터페이스 완전 구현이 보장된다.
  • requests.postunittest.mock으로 가로막고 다음을 검증하는 임시 테스트 스크립트를 작성해 전부 통과시켰다(저장소에는 포함하지 않음, 세션 스크래치패드에만 존재):
    • get_price/get_orderbook 응답 파싱(호가 10단계 포함)
    • get_ohlcvfrdate~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.pyast.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.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는 국내주식 중심 인터페이스라 해외/파생 자산군의 필드(외화, 증거금 등)를 그대로 담기 어렵다.