diff --git a/.gitignore b/.gitignore index 4c7c3fe..b5b159b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ venv/ .env.* !.env.example +# 로컬 전용 문서 (원격 저장소에는 올리지 않음) +doc/ + # --- JetBrains 공식 .gitignore 파일 명세 --- # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 diff --git a/doc/IMPROVEMENTS.md b/doc/IMPROVEMENTS.md deleted file mode 100644 index 65e49ef..0000000 --- a/doc/IMPROVEMENTS.md +++ /dev/null @@ -1,217 +0,0 @@ -# finestock 개선 제안 - -코드베이스 전반(`finestock/`, `tests/`, 루트 예제 스크립트, 패키징 파일)을 훑어보고 발견한 문제점과 개선 방향을 정리했다. 심각도가 높은 순으로 배치했다. - -## 0. 아키텍처 - -버그/코드 스멜과는 별개로, 설계 자체를 평가하면 다음과 같다. - -### 잘 된 부분 - -- **Facade + Factory**: `create_api(APIProvider.X)`로 브로커 하나를 받아 인증/시세/주문/실시간을 한 객체에서 다루게 한 것은 "브로커를 갈아끼워도 호출부는 그대로"라는 목표에 잘 맞는 선택이다. `APIFactory`가 브로커별 모듈을 lazy-import하는 것도 미사용 브로커의 의존성을 끌고 오지 않는 합리적인 설계다. -- **`path.py`로 도메인/엔드포인트를 데이터화**: URL 문자열이 코드 곳곳에 흩어지지 않고 `_API_PATH_[클래스명]` 한 곳에 모여 있다. `EBest(LS)`, `LSV(LS)`, `KiwoomV(Kiwoom)`처럼 실전↔모의 차이가 "도메인만 다름"인 브로커는 실제로 상속만으로 몇 줄짜리 서브클래스가 된다(`ebest.py`, `ls_v.py`, `kiwoom_v.py` 확인 완료). **이 축(도메인 차이)에 대해서는 설계 의도대로 잘 작동한다.** -- **Queue 주입 방식의 실시간 데이터 흐름**: 콜백 지옥 대신 `set_data_queue()`로 소비자가 원하는 큐를 주입받는 구조라, WS 루프와 소비자 로직이 느슨하게 결합된다. asyncio 루프 안에 비즈니스 로직을 얽어 넣지 않아도 되는 점은 장점이다. -- **frozen dataclass로 정규화된 모델**: 브로커마다 제각각인 JSON 응답을 `Price`/`Order`/`Trade` 등 불변 객체로 통일해서 반환하는 규약 자체는 일관되게 지켜지고 있다(`Kiwoom.do_order`/`do_order_cancel`만 이 규약을 깨고 raw dict를 반환하는 예외 — 5장 참고). - -### 걸리는 부분 - -**0.1 ISP가 타입 힌트 수준에서만 존재하고, 실제로는 거꾸로 작동한다.** -`BaseProvider`가 6개 인터페이스를 전부 합친 하나의 거대 클래스라서, 브로커가 지원하지 않는 기능도 무조건 구현해야 한다. 그 결과가 `Kis.get_index_list`의 `print("Kis not supported")`, `Kis.recv_price`/`recv_index`/... 의 빈 `pass`들이다. ISP의 원래 취지는 "클라이언트가 쓰지 않는 메서드에 의존하지 않게 하자"인데, 여기서는 "구현체가 쓸 수 없는 메서드도 강제로 구현하게" 만드는 쪽으로 뒤집혀 있다. 게다가 타입 힌트로 좁히는 것(`market_api: MarketDataProvider = full_api`)은 IDE 자동완성용일 뿐 런타임 강제력이 없어서, `TradingProvider`로 좁혀 받은 코드가 실수로 `do_order`를 호출하는 걸 막아주지 않는다. mypy 같은 정적 타입 검사가 파이프라인에 없어 이 이점조차 실제로는 활용되지 못하고 있다. - -**0.2 상속 축이 하나뿐이라, 두 번째 변형 축(TR ID 차이)이 나오자 코드 중복으로 샌다.** -`path.py` 기반 상속은 "도메인만 다름" 케이스에는 잘 맞지만, KIS는 실전/모의가 **도메인도 다르고 TR ID도 다르다**. TR ID는 `path.py`처럼 데이터화되어 있지 않고 각 메서드 안에 문자열 리터럴로 박혀 있다(`header["tr_id"] = "TTTC8434R"`). 그래서 `KisV`는 `get_balance`, `do_order`를 새로 쓰는 대신 **거의 통째로 복붙**해서 TR ID 한 줄만 바꾼 형태가 됐다(`kis_v.py`). 이는 상속 설계 자체보다 "브로커별로 달라지는 축이 URL 하나가 아니라는 것"을 처음부터 모델링하지 않은 결과다. TR ID/설정값도 `path.py`처럼 별도 딕셔너리(`_TR_IDS_["Kis"]` vs `_TR_IDS_["KisV"]`)로 빼서 메서드 본문은 공유하고 설정만 주입하는 방식이었다면 `KisV`도 지금의 `EBest`/`LSV`만큼 얇아졌을 것이다. 이건 4장에 적은 코드 중복의 근본 원인이기도 해서, 아키텍처 레벨에서 고치면 4장 항목이 상당 부분 같이 해결된다. - -**0.3 HTTP/WS 클라이언트가 각 메서드에 직접 박혀 있어 교체·테스트·횡단 관심사 주입이 불가능하다.** -`requests.post(...)`가 브로커당 10~15곳에 그대로 호출된다. 재시도, 레이트리밋, 타임아웃 정책, 목(mock) 대체를 넣으려면 그 10~15곳을 전부 고쳐야 한다는 뜻이다. 트랜스포트 계층(`self._client.post(...)` 같은 얇은 어댑터)을 한 겹 두는 것만으로 테스트 가능성과 횡단 관심사 적용이 동시에 해결되는데, 지금은 그 경계가 아예 없다(6장 테스트 커버리지 부재의 근본 원인이기도 하다). - -**0.4 `_init_path()`가 동적 `setattr`로 인스턴스 속성을 만들어서, "IDE 자동완성"이라는 설계 목표를 스스로 깎아먹는다.** -`API._init_path()`는 딕셔너리를 순회하며 `self.DOMAIN`, `self.CHART` 등을 런타임에 만든다(`api.py:29-32`). README가 내세우는 설계 목표 중 하나가 "타입 힌팅으로 IDE 자동완성이 되게 한다"인데, 정작 `self.DOMAIN`/`self.CHART` 같은 핵심 속성은 정적으로 선언되어 있지 않아 IDE/mypy가 이 속성들의 존재를 모른다. `ClassVar`가 선언된 설정 dataclass(브로커별 서브클래스)로 바꾸면 중앙화 이점은 유지하면서 정적 분석 이점도 되찾을 수 있다. - -**0.5 동기(REST)와 비동기(WS)가 한 객체에 공존하는데 그 경계에 대한 설계가 없다.** -같은 파사드 객체가 블로킹 `requests` 호출 메서드와 `async def run()`/`recv_price` 같은 코루틴 메서드를 동시에 갖고 있다. asyncio 이벤트 루프를 돌리면서(`run()`) 그 안에서 블로킹 `get_ohlcv`를 호출하면 루프 전체가 멈춘다. 브로커 API 자체가 REST+WS 혼합이라 완전히 피하긴 어렵지만, 최소한 "REST 메서드는 블로킹이니 `asyncio.to_thread`로 감싸서 써라" 같은 경계 가이드나 헬퍼가 전혀 없다. - -**0.6 실시간 연결의 회복탄력성이 설계에 아예 없다.** -`LS.run()`/`Kiwoom.run()`은 예외가 나면 그냥 `is_run = False`로 루프를 끝내버린다(재연결, 백오프, 재구독 없음). 실거래 봇의 실시간 클라이언트에서 이건 버그라기보다 "애초에 그 관심사를 다루는 레이어가 설계에 없다"는 문제라, 지금 구조 위에 패치를 얹기보다 재연결/재구독을 담당하는 별도 계층을 설계 단계에서 넣는 게 낫다. - -### 아키텍처 개선 우선순위 - -Facade+Factory+ISP라는 큰 뼈대 자체는 목적에 맞고, "도메인 URL이 다른" 축까지는 실제로 깔끔하게 작동한다. 문제는 그 뼈대가 **한 개의 변형 축(도메인)만 상정**하고 있어서, TR ID 차이·기능 미지원·동기/비동기 혼재·트랜스포트 계층 부재 같은 나머지 축들이 전부 상속 복붙이나 빈 스텁으로 새어나가고 있다는 점이다. 다음 순서로 손대면 4~6장의 개별 항목 상당수가 부수적으로 정리된다. - -1. TR ID/설정을 `path.py`처럼 데이터화해서 `KisV`류의 메서드 복붙 제거 -2. 트랜스포트 계층(공통 요청 헬퍼/어댑터) 한 겹 추가 — 3장의 에러 처리 개선과 통합 -3. `BaseProvider`를 강제 합성 대신, 브로커가 실제 지원하는 인터페이스만 mix-in 하는 구조로 전환 - -## 1. 보안 (긴급) - -### 1.1 실제 API 키/토큰이 저장소에 평문으로 존재 -`example.py`, `example_async.py`에 실제로 보이는 `app_key`, `app_secret`, 계좌번호, JWT 형식의 `access_token`이 하드코딩되어 있다. - -```python -# example.py -app_key = "PS6HIMzSNcpfSU29Qkdlr17szGoZmPMZ3kOC" -app_secret = "fkaR4TJD3SU2MrbjIHHWbCufxIPcMAs1" -account_num = "207087079" -access_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9..." -``` - -**문제**: 현재 `.gitignore`는 JetBrains 관련 항목만 포함하고 있어 `example.py`, `debug.log`, `venv/`, `build/`, `dist/`, `__pycache__/`, `finestock.egg-info/`를 걸러주지 않는다(`git status`에 전부 `??`로 나타남). 즉 무심코 `git add .` 한 번이면 실키가 원격 저장소에 올라간다. 이미 커밋되지 않았는지도 확인이 필요하다. - -**조치**: -- 즉시 위 키/토큰을 브로커사 콘솔에서 폐기(재발급)한다. -- `.gitignore`에 `debug.log`, `__pycache__/`, `build/`, `dist/`, `*.egg-info/`, `venv/`를 추가한다. -- 예제 파일의 실키를 `os.environ["LS_APP_KEY"]` 같은 환경변수 참조 또는 `YOUR_APP_KEY` 플레이스홀더로 교체한다. -- 저장소 히스토리에 이미 커밋된 적이 있다면 `git filter-repo`/BFG로 히스토리에서 제거하는 것도 검토한다. - -### 1.2 자격증명이 디버그 로그에 그대로 남는다 -`API.oauth()`(`finestock/comm/api.py:67`), `LS`의 거의 모든 TR 메서드, `Kis.get_ohlcv` 등에서 요청 헤더/바디를 통째로 `logger.debug`로 남긴다. 헤더에는 `appkey`, `appsecret`, `authorization: Bearer `이 포함되어 있어 `debug.log`가 곧 자격증명 덤프가 된다. `debug.log`가 `.gitignore`에도 없어 실수로 커밋될 위험까지 겹친다(1.1 참고). - -**조치**: 로깅 전에 민감 필드를 마스킹하는 헬퍼(`_redact(headers)`)를 만들어 `appkey`, `appsecret`, `authorization` 값을 `***`로 치환한 뒤 로깅한다. - -## 2. 실제 동작 버그 - -### 2.1 `Kis.do_order`의 매도 TR ID에 트레일링 공백 -```python -# finestock/kis/kis.py:152 -header["tr_id"] = "TTTC0802U" if buy_flag == finestock.ORDER_FLAG.BUY else "TTTC0801U " # ← 끝에 공백 -``` -매도 주문 시 TR ID가 `"TTTC0801U "`로 전송되어 KIS 서버가 이를 유효하지 않은 TR로 거부할 가능성이 높다. 실거래 주문 코드의 오타이므로 우선순위가 높다. - -### 2.2 KIS 조회 메서드의 기본 인자가 import 시점에 고정됨 -```python -# finestock/kis/kis.py:47 -def get_ohlcv(self, code, frdate=datetime.now().strftime('%Y%m%d'), todate=datetime.now().strftime('%Y%m%d')): -``` -`get_index`도 동일 패턴(`kis.py:70`). 파이썬은 함수 정의 시점에 기본값을 한 번만 평가하므로, 모듈을 import한 날짜가 그대로 굳어버린다. 프로세스를 며칠 이상 띄워두는 트레이딩 봇에서 인자를 생략하고 호출하면 실제로는 "오늘"이 아니라 "프로세스 시작일"의 시세를 조회하게 된다. - -**조치**: `frdate=None`으로 받고 함수 본문에서 `frdate = frdate or datetime.now().strftime('%Y%m%d')`로 매 호출마다 평가한다. - -### 2.3 `Kiwoom.get_ohlcv_min`에 중복 `return` -```python -# finestock/kiwoom/kiwoom.py:127-129 -return self._get_chart_sync("ka10080", params, next_key=tr_cont_key, frdate=cts_date if cts_date.strip() else "") - -return self._get_chart_sync("ka10080", params, next_key=tr_cont_key, frdate=cts_date if cts_date.strip() else "") -``` -두 번째 줄은 도달 불가능한 죽은 코드. 복붙 실수로 보이며 삭제 대상. - -### 2.4 `LS.get_index_list`가 항상 `None` 반환 -```python -# finestock/ls/ls.py:282-299 -def get_index_list(self): - ... - response = requests.post(...) - res = response.json() - logger.debug(...) - # ← 여기서 끝. res를 파싱해서 return하는 코드가 없음 -``` -호출부는 항상 `None`을 받는다. 인터페이스(`InfoProvider.get_index_list`)의 계약을 지키지 못하고 있어 구현이 비어있는 상태에 가깝다. - -### 2.5 `path.py`의 `_KIWOOM_V_`에서 `DOMAIN` 키를 두 번 정의 -```python -# finestock/path.py:66-76 -_KIWOOM_V_ = { - **_KIWOOM_, - "DOMAIN": "https://api.kiwoom.com", # ← 바로 아래서 덮어써짐, 의미 없는 죽은 코드 - ... - "DOMAIN": "https://mockapi.kiwoom.com", - "DOMAIN_WS": "wss://mockapi.kiwoom.com:10000/api/dostk/websocket", -} -``` -동작 결과는 두 번째 값(`mockapi.kiwoom.com`)으로 정상 동작하지만, 첫 값과 그 위의 탐색용 주석("Wait, main DOMAIN is..." 등)이 그대로 남아있어 다음에 코드를 읽는 사람이 혼란스럽다. 결론만 남기고 정리 필요. - -### 2.6 `LSV`(모의투자)가 `LS.oauth()`의 `scope: "oob"`를 그대로 상속 -`finestock/ls/ls_v.py`를 보면 `DOMAIN`/`DOMAIN_WS`만 오버라이드하고 `oauth()`는 상속받는데, 모의투자 서버가 실전과 다른 scope 처리를 요구하는지 확인이 필요하다(문제가 없다면 무시해도 됨 — 다만 EBest/LS/Kiwoom 각 모의 서버별 OAuth 파라미터 차이를 점검할 가치가 있다). - -## 3. 에러 처리 부재 — 트레이딩 라이브러리로서 가장 큰 리스크 - -거의 모든 브로커 메서드가 다음 패턴을 따른다: - -```python -response = requests.post(url, headers=header, data=json.dumps(body)) -res = response.json() # ← HTTP 에러/타임아웃/비-JSON 응답이면 여기서 그대로 예외 전파 -if res['rsp_cd'] == "00000": # ← 키가 없으면 KeyError - ... - return ohlcvs -# else: 암묵적으로 None 반환 — 실패 사유를 알 수 없음 -``` - -- **`requests` 호출에 `timeout`이 전혀 지정되지 않았다.** 브로커 서버가 응답을 지연하면 스레드가 무기한 블록된다. 실거래 봇에서는 치명적이다. 최소 `timeout=(3, 10)` 같은 값을 전역 상수로 정해 모든 호출에 적용해야 한다. -- **HTTP 상태 코드/네트워크 예외를 확인하지 않는다.** `LS`, `Kis`의 대부분 메서드는 `response.status_code`도 안 보고 바로 `.json()`을 호출한다(반면 `Kiwoom`은 `status_code == 200` 체크를 하는 편이라 상대적으로 낫다). 브로커 서버가 502/503을 반환하거나 HTML 에러 페이지를 주면 `JSONDecodeError`가 호출자에게 그대로 전파된다. -- **실패 시 반환값이 전부 `None`(혹은 `[]`) 이고 원인 구분이 불가능하다.** "종목이 없어서 결과가 비었다"와 "네트워크 오류로 실패했다"와 "토큰 만료로 인증 실패했다"를 호출자가 구분할 방법이 없다. - -**조치 제안**: -1. `finestock/comm/errors.py`에 `FinestockAPIError`, `FinestockAuthError`, `FinestockNetworkError` 같은 예외 계층을 만든다. -2. `API` 베이스에 공통 요청 헬퍼(`_post_json(url, headers, body, timeout=...)`)를 추가해 timeout 지정, HTTP 상태 확인, JSON 파싱 실패 처리, 표준 로깅을 한 곳에서 담당하게 하고, 각 브로커는 이 헬퍼를 호출하도록 리팩터링한다. -3. 실패 시 `None`을 반환하는 대신 예외를 던지거나, 최소한 로그 레벨을 `error`로 남겨 원인이 추적 가능하게 한다. - -## 4. 심한 코드 중복 — 유지보수 비용 - -`finestock/ls/ls.py`의 거의 모든 메서드(`get_ohlcv`, `get_ohlcv_min`, `get_index`, `get_index_min`, `get_index_list`, `get_stock_list`, `get_news_list`, `get_condition_list`, `get_condition_price`, `get_orderbook`, `get_balance`, `get_holds`, `do_order`, `get_order_status`, `do_order_cancel`)가 다음 5줄짜리 보일러플레이트를 그대로 반복한다: - -```python -response = requests.post(url, headers=header, data=json.dumps(body)) -res = response.json() -logger.debug(f"[API: oauth]\n" # ← 메서드 이름과 무관하게 항상 "oauth"라고 찍힘(복붙 흔적) - f"[URL: {url}]\n" - f"[header: {header}]\n" - f"[param: {body}]\n" - f"[response: {res}]") -``` -로그 라벨이 실제 호출과 무관하게 전부 `[API: oauth]`로 찍히는 것도 복붙의 흔적이며, 로그로 문제를 추적할 때 오히려 혼선을 준다. - -`Kiwoom` 쪽도 `header = {..., "authorization": ..., "api-id": tr_code, "cont-yn": ..., "next-key": ...}` 패턴과 `response.status_code == 200` → `try: res = response.json() ...` 블록이 `_get_chart_sync`, `get_stock_list`, `get_index_list`, `get_orderbook`, `do_order`, `do_order_cancel`, `get_balance`, `get_holds`에서 거의 동일하게 반복된다. - -**조치**: 브로커별로 `_request(tr_cd_or_api_id, block_key, body) -> dict` 형태의 공통 헬퍼를 만들어 URL 조립, 헤더 구성, 요청, 로깅, 에러 처리를 한 곳으로 모은다. TR별 바디 구성과 응답 파싱만 각 메서드에 남기면 코드량이 절반 이하로 줄고, 3장의 에러 처리 개선도 자연히 여기에 녹여 넣을 수 있다. - -## 5. 인터페이스(계약) 불일치 - -`api_interface.py`가 정의한 추상 메서드 시그니처와 실제 구현이 어긋나는 곳들: - -- `MarketDataProvider.get_stock_list(self, mrkt_tp: str = "0")` — `LS.get_stock_list(self)`는 인자를 아예 받지 않는다(`ls.py:301`). `Kiwoom.get_stock_list(self, mrkt_tp="0")`만 계약을 지킨다. -- `RealtimeProvider.recv_trade(self, code, status=True)`는 `async` 추상 메서드인데 `LS.recv_trade(self, code)`(`ls.py:731`)는 **동기 함수**이고 `status` 인자도 없다. `await api.recv_trade(...)`로 호출하면 코루틴이 아니라서 `TypeError`가 난다. -- `Kiwoom.do_order` / `Kiwoom.do_order_cancel`은 `finestock.Order` 데이터클래스가 아니라 브로커 원본 응답 `dict`를 그대로 반환한다. `LS`/`Kis`는 `finestock.Order`를 반환하므로, `Kiwoom`으로 브로커를 바꾸는 순간 호출부 코드가 깨진다 — 파사드 패턴의 "브로커를 갈아끼워도 동일하게 동작"이라는 목적이 깨지는 지점이다. -- `KisV.do_order_cancle`(오타, 스텁)은 CLAUDE.md에도 이미 기록되어 있지만 아직 수정되지 않았다. `finestock.model.flag.TRADE_FLAG.CANCLE`도 동일한 오타가 enum 멤버명에 박혀 있어(`flag.py:6`) 모든 브로커 구현이 이 오타를 그대로 참조하고 있다 — 지금 고치면 파급 범위가 크므로, `CANCEL`을 별도 별칭으로 추가하고 `CANCLE`을 deprecated 처리 후 다음 메이저 버전에서 제거하는 단계적 마이그레이션을 권장한다. - -**조치**: 브로커 간 치환 가능성이 핵심 가치이므로, 위 불일치들을 CI에서 잡을 수 있도록 6장의 계약 테스트를 도입하는 것이 근본적 해법이다. - -## 6. 테스트 커버리지 - -- `tests/test_model.py`는 `Price` 데이터클래스 생성 하나만 검증한다. `LS`/`Kis`/`Kiwoom`의 JSON 파싱 로직(`_parse_ohlcv`, `_parse_orderbook`, `_parse_real_price` 등)은 테스트가 전혀 없다 — 5장에서 지적한 회귀들이 아무 신호 없이 계속 잠재해 있던 이유이기도 하다. -- `requests`/`websockets` 호출을 `unittest.mock`으로 가로채는 테스트가 없어, 브로커가 응답 스키마를 바꿔도 감지할 방법이 없다. -- CI 설정(`.github/workflows/*.yml`)이 없어 PR/push 시 `python -m unittest discover tests`조차 자동 실행되지 않는다. - -**조치**: -1. 각 브로커의 대표 응답 JSON을 fixture로 저장하고, `requests.post`/`.get`을 mock으로 대체해 파싱 로직만 검증하는 단위 테스트를 추가한다. -2. `BaseProvider`의 각 추상 메서드에 대해 "모든 구현체가 동일한 시그니처인지" 확인하는 계약 테스트(예: `inspect.signature` 비교)를 추가해 5장 같은 회귀를 CI에서 잡는다. -3. GitHub Actions로 `python -m unittest discover tests`를 push/PR마다 실행한다. - -## 7. 로깅/디버그 잔재 정리 - -- `print()`와 `loguru.logger`가 뒤섞여 있다(`LS.__init__`의 `print("create LS Components")`, `Kis`/`KisV`의 여러 `print(res)`, `Kiwoom.get_balance`의 `print(response.text)` / `print(res)`). 특히 `Kiwoom.get_balance`가 계좌 잔고 원본 응답을 `print`로 stdout에 그대로 흘리는 것은 1.2와 같은 맥락의 정보 노출이다. -- `__del__`에서 `logger.debug(...)`를 호출하는 패턴(`API`, `LS`)은 인터프리터 종료 시점에 모듈 전역이 이미 해제되어 있을 수 있어 예외를 유발할 수 있다. 일반적으로 `__del__`에 로깅/IO를 넣는 것은 권장되지 않는다 — 필요하다면 명시적 `close()`/context manager 패턴으로 대체하는 것이 안전하다. - -**조치**: 모든 `print`를 `logger.debug`/`logger.info`로 통일하고, 민감 데이터(잔고, 토큰, 키)는 로그에서 마스킹한다. - -## 8. 패키징 / 배포 메타데이터 - -- `setup.py`의 `install_requires`에 `asyncio`가 포함되어 있다. `asyncio`는 Python 3.4+ 표준 라이브러리이며 PyPI 패키지로 명시하면 안 된다(과거 PyPI에 이름이 겹치는 악성 패키지가 올라온 사례도 있었다). 제거해야 한다. -- `version='1.0.1.0'`은 4-part 버전으로 PEP 440 기준 `1.0.1.0`도 파싱은 되지만 관례상 `MAJOR.MINOR.PATCH` 3-part(semver 유사)를 쓰는 것이 `CHANGELOG.md`와 대응시키기 쉽다. -- `python_requires='>=3.6'` / `classifiers`에 `3.6~3.9`만 나열되어 있지만, 리포지토리의 `__pycache__`에는 `cpython-310/311/312` 산출물이 있어 실제 개발/검증은 3.10~3.12에서 이뤄지고 있는 것으로 보인다. 코드 안에서 f-string(3.6+)과 `dataclass(frozen=True)`(3.7+)를 쓰므로 최소 버전을 3.7~3.8 이상으로 올리고, 실제 CI로 검증한 버전만 classifiers에 남기는 것을 권장한다. -- `setup.py`만 있고 `pyproject.toml`이 없다. 최신 packaging 관례(PEP 517/518)를 따르는 `pyproject.toml` 도입을 검토할 만하다. - -## 9. 문서 - -- `README.md`의 "2. 실시간 데이터" 섹션에서 ` ```python ` 코드 펜스가 닫히지 않은 채 "3. 타입 힌팅 활용" 섹션으로 이어지고, 그 뒤에 다시 ` ```python `이 열려 파일 끝의 ` ``` ` 한 줄과 짝이 맞지 않는다(`README.md:67~97`). 현재 GitHub에서 렌더링하면 3번 섹션 전체가 코드 블록으로 표시될 가능성이 높다. -- 목차에 `5. License` 항목이 있지만 실제 License 섹션 본문이 파일에 없다 — 링크가 깨진 앵커로 남아있다. 라이선스 파일(`LICENSE`)도 리포지토리에 없다. 배포 패키지로서 라이선스 명시가 필요하다. -- README가 LS/KIS 예제만 보여준다. 최근 추가된 Kiwoom 브로커(`finestock/kiwoom/`)에 대한 사용 예시가 없어, `CLAUDE.md`가 설명하는 4개 브로커 지원 범위와 README의 소개 범위("LS, KIS")가 어긋난다. - -## 요약: 우선순위별 실행 순서 - -| 우선순위 | 항목 | -|---|---| -| 1 (즉시) | 1.1 노출된 실키/토큰 폐기 및 `.gitignore` 보강, 1.2 로그 마스킹 | -| 2 | 2.1 KIS 매도 TR ID 공백 버그, 2.2 KIS 기본 인자 고정 버그 | -| 3 | 0.2 TR ID/설정 데이터화, 0.3 트랜스포트 계층 도입 → 3장 공통 요청 헬퍼(timeout, 에러 처리, 예외 계층)와 통합 | -| 4 | 0.1 인터페이스 mix-in 구조 전환, 5장 인터페이스 불일치 해소 + 6장 계약/파싱 테스트 및 CI 도입 | -| 5 | 4장 중복 제거 리팩터링(0.2와 연동), 7장 로깅 정리, 0.5/0.6 동기·비동기 경계·재연결 설계 | -| 6 | 0.4 설정 객체 정적 타입화, 8장 패키징 정리, 9장 README/LICENSE 정비 | diff --git a/doc/NH_INTEGRATION.md b/doc/NH_INTEGRATION.md deleted file mode 100644 index 1a449d2..0000000 --- a/doc/NH_INTEGRATION.md +++ /dev/null @@ -1,93 +0,0 @@ -# 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`는 국내주식 중심 인터페이스라 해외/파생 자산군의 필드(외화, 증거금 등)를 그대로 담기 어렵다.