Merge branch 'feature/nh-broker-integration' into main
NH(나무) 브로커 통합 및 get_balance() 잔고/보유종목 파싱 버그 수정. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01225Lu4Fc2UpMz6QixEcNT8
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# finestock 예제 스크립트(example.py, example_async.py 등)에서 사용하는 환경변수 템플릿.
|
||||
# 이 파일을 복사해 .env 로 저장한 뒤 실제 값을 채워 넣으세요. .env는 .gitignore에 의해
|
||||
# 커밋되지 않습니다. 절대 실제 키/시크릿/토큰 값을 이 파일(.env.example)에 직접 채워
|
||||
# 커밋하지 마세요 — 이 파일은 어떤 값이 필요한지 보여주는 템플릿일 뿐입니다.
|
||||
#
|
||||
# 사용 예 (PowerShell):
|
||||
# $env:APP_KEY = "..."
|
||||
# $env:APP_SECRET = "..."
|
||||
#
|
||||
# 사용 예 (python-dotenv 등으로 .env 파일을 직접 로드하는 경우):
|
||||
# from dotenv import load_dotenv; load_dotenv()
|
||||
|
||||
# 브로커 앱 키 / 시크릿 (LS/EBest/KIS/Kiwoom/NH 공통 — 사용하는 브로커의 개발자 콘솔에서 발급)
|
||||
APP_KEY=YOUR_APP_KEY
|
||||
APP_SECRET=YOUR_APP_SECRET
|
||||
|
||||
# 계좌번호 (앞자리/뒤 2자리 구분이 필요한 브로커의 경우)
|
||||
ACCOUNT_NUM=YOUR_ACCOUNT_NUM
|
||||
ACCOUNT_NUM_SUB=01
|
||||
|
||||
# 이미 발급받은 access_token을 재사용하고 싶을 때만 채운다(선택 사항).
|
||||
# 보통은 비워두고 코드에서 api.oauth()를 호출해 새로 발급받는 편을 권장.
|
||||
ACCESS_TOKEN=
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
# --- finestock 프로젝트 전용 ---
|
||||
|
||||
# Python 빌드/캐시 산출물
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# 가상환경
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# 로그 (자격증명이 로깅될 수 있으므로 반드시 제외)
|
||||
*.log
|
||||
|
||||
# 로컬 환경변수/시크릿 (예: app_key, app_secret, access_token)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# --- 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
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# SonarLint plugin
|
||||
.idea/sonarlint/
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.11" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_22" project-jdk-name="Python 3.12" project-jdk-type="Python SDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
<component name="PyPackaging">
|
||||
<option name="earlyReleasesAsUpgrades" value="true" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/finestock.iml" filepath="$PROJECT_DIR$/.idea/finestock.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,63 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project overview
|
||||
|
||||
`finestock` is a Python package unifying Korean stock brokerage OpenAPIs (EBest, LS, KIS, Kiwoom, NH — live and mock/simulated variants) behind one facade object. It wraps each broker's REST/WebSocket API and normalizes results into shared dataclasses (`Price`, `OrderBook`, `Account`, `Order`, `Trade`, ...).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pip install -e . # install package in editable mode (requirements: websockets, requests, loguru)
|
||||
python -m unittest discover tests # run tests (unittest, not pytest — no pytest config in repo)
|
||||
python -m unittest tests.test_model -v # run a single test module
|
||||
```
|
||||
|
||||
There is no lint/format tooling configured in the repo.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Facade + Factory + ISP
|
||||
|
||||
`finestock.create_api(APIProvider.X)` (thin wrapper around `APIFactory.create_api`, in `finestock/api_factory.py`) lazy-imports and instantiates one broker class. Every broker class multiply-inherits `BaseProvider` (`finestock/comm/api_interface.py`), a union of segregated `ABC` interfaces:
|
||||
|
||||
- `AuthenticationProvider` — oauth/token handling
|
||||
- `MarketDataProvider` — price/OHLCV/index/orderbook queries
|
||||
- `TradingProvider` — order placement/cancellation
|
||||
- `RealtimeProvider` — WebSocket subscribe/unsubscribe (`recv_price`, `recv_orderbook`, etc.)
|
||||
- `AccountProvider` — balance/holdings
|
||||
- `InfoProvider` — stock/index master lists
|
||||
|
||||
Callers narrow the fat facade object to one interface via type hints (`market_api: MarketDataProvider = full_api`) purely for IDE autocomplete — there is no runtime restriction. When adding a method, add it to the relevant interface in `api_interface.py` first, then implement it in each concrete broker.
|
||||
|
||||
### Class hierarchy per broker
|
||||
|
||||
Each broker directory (`finestock/{ebest,ls,kis,kiwoom,nh}/`) has a real/live class and, in most cases, a mock-trading (`*V`) subclass that only overrides `DOMAIN`/`DOMAIN_WS` (via `finestock/path.py`) or a handful of TR IDs:
|
||||
|
||||
- `finestock.comm.api.API` — shared base (`finestock/comm/api.py`): holds `app_key`/`app_secret`/`access_token`, generic `headers` dict, generic `oauth()` (client_credentials POST), and `set_data_queue`/`add_data` for realtime fan-out.
|
||||
- `LS(API)` — the fullest, canonical implementation (`finestock/ls/ls.py`, ~860 lines): TR-code-based REST calls (`t8410`, `t8407`, `t8452`, ...) plus a WebSocket `connect`/`run`/`recv_*` loop keyed by `tr_cd`.
|
||||
- `EBest(LS)` — EBest's API is wire-compatible with LS, so it inherits everything and only swaps `DOMAIN`/`DOMAIN_WS` via `path.py`.
|
||||
- `LSV(LS)` — LS mock trading; same overrides pattern.
|
||||
- `Kis(API)` (`finestock/kis/kis.py`) — KIS uses `tr_id` header-based REST TRs.
|
||||
- `KisV(Kis)` — mock trading; overrides `get_balance`/`do_order` with VTS-prefixed `tr_id`s (`VTTC...` vs `TTTC...`).
|
||||
- `Kiwoom(API)` (`finestock/kiwoom/kiwoom.py`, ~850 lines) — Kiwoom's REST+WS shape differs most from the others (`api-id` header instead of `tr_cd`/`tr_id`, `trnm`/`REG` WS subscription protocol).
|
||||
- `KiwoomV(Kiwoom)` — mock trading, `DOMAIN`/`DOMAIN_WS` swapped to `mockapi.kiwoom.com`.
|
||||
- `Nh(API)` (`finestock/nh/nh.py`) — NH투자증권(나무/Namuh) uses a uniform `POST {"Input_0": {...}}` → `{rsp_cd, Output_0[, Output_1, Output_2], message}` envelope for every REST TR, auth via `Authorization`/`x-client-id`/`x-client-secret` headers (`set_oauth_info` overridden to set the latter two). No dedicated 호가/지수 TRs — `get_orderbook` reuses the `currentPrice` TR's `askp1..10`/`bidp1..10` fields, and `get_index`/`get_index_list`/`get_stock_list` are unimplemented stubs (지수 TR 없음; 종목은 REST가 아니라 `.mst` 마스터 파일로만 제공). `oauth()` always targets the fixed live domain (`OAUTH_DOMAIN` in `path.py`) even for `NhV`, since 접근토큰발급 is live-only regardless of which domain trades run against.
|
||||
- `NhV(Nh)` — mock trading (`moapi.nhplug.com`); no method overrides needed, only `DOMAIN`/`DOMAIN_WS` swapped via `path.py`.
|
||||
|
||||
`finestock/path.py` centralizes all per-broker base URLs and endpoint path fragments in dict constants (`_LS_`, `_KIS_`, `_KIWOOM_`, ...) merged into `_API_PATH_`, keyed by class name; `API._init_path()` reads `_API_PATH_[self.api_type]` and sets each entry as an instance attribute (so `self.DOMAIN`, `self.CHART`, `self.ORDER`, etc. exist post-`__init__`). When adding a broker or endpoint, edit `path.py`, not the broker class, unless the URL needs runtime logic.
|
||||
|
||||
### Data models (`finestock/model/`)
|
||||
|
||||
All public dataclasses are frozen (`@dataclass(frozen=True)`) and re-exported through `finestock/model/__init__.py` and top-level `finestock/__init__.py`. Broker methods parse raw JSON/TR responses and construct these dataclasses directly (e.g. `finestock.Price(...)`, `finestock.Order(...)`) rather than returning raw dicts — new broker code should follow the same convention. `Price.from_values` / `Price.from_series` are the two supported construction paths for `Price` beyond the raw constructor.
|
||||
|
||||
### Realtime data flow
|
||||
|
||||
Realtime WebSocket data does not use callbacks; a caller injects a `queue.Queue`-like object via `set_data_queue()` (in `comm/api.py`), and broker WS loops (`LS.run`, `Kiwoom.run`) push parsed messages onto it via `add_data`/`add_price`/`add_trade`/`add_orderbook`. Broker-specific TR/type codes in the incoming WS frame determine how a message is parsed and dispatched.
|
||||
|
||||
## Conventions worth knowing
|
||||
|
||||
- Continuation/pagination in REST calls follows a broker-specific `cts_date`/`cts_time`/`tr_cont_key` (LS, Kiwoom) or `CTX_AREA_FK100`/`NK100` (KIS) pattern — recursive calls with `time.sleep()` between pages are the existing pattern for LS's `get_ohlcv`.
|
||||
- Method names are standardized across brokers per `CHANGELOG.md` (PEP 8, single-underscore private prefix, `do_order_cancel` not `do_order_cancle`) — but `KisV.do_order_cancle` (kis_v.py) still uses the old misspelled name and is a stub (`pass`); don't assume it matches the interface's `do_order_cancel` without checking.
|
||||
- `debug.log` at the repo root is loguru output, not source.
|
||||
@@ -6,10 +6,11 @@ Created by alshin
|
||||
|
||||
## Table of Contents
|
||||
1. [설치](#설치)
|
||||
2. [아키텍처](#아키텍처)
|
||||
3. [사용법](#사용법)
|
||||
4. [Release Notes](#release-notes)
|
||||
5. [License](#license)
|
||||
2. [환경변수 설정 (.env)](#환경변수-설정-env)
|
||||
3. [아키텍처](#아키텍처)
|
||||
4. [사용법](#사용법)
|
||||
5. [Release Notes](#release-notes)
|
||||
6. [License](#license)
|
||||
|
||||
---
|
||||
|
||||
@@ -21,6 +22,65 @@ pip install finestock
|
||||
|
||||
---
|
||||
|
||||
## 환경변수 설정 (.env)
|
||||
|
||||
브로커 앱키/시크릿/계좌번호/액세스 토큰은 코드에 직접 하드코딩하지 말고 환경변수로 주입한다. 저장소 루트의 `.env.example`을 복사해 `.env`로 만들고 실제 값을 채워 넣는다.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
`.env` 파일 내용:
|
||||
|
||||
```
|
||||
APP_KEY=YOUR_APP_KEY
|
||||
APP_SECRET=YOUR_APP_SECRET
|
||||
ACCOUNT_NUM=YOUR_ACCOUNT_NUM
|
||||
ACCOUNT_NUM_SUB=01
|
||||
ACCESS_TOKEN=
|
||||
```
|
||||
|
||||
`.env`는 `.gitignore`에 의해 커밋되지 않는다(`.env.example`만 커밋 대상).
|
||||
|
||||
### 값 로드 방법
|
||||
|
||||
`example.py`, `example_async.py`는 모두 `os.environ.get("APP_KEY", ...)` 형태로 값을 읽는다. `.env` 파일 자체는 셸이나 파이썬이 자동으로 읽어주지 않으므로 아래 두 방법 중 하나가 필요하다.
|
||||
|
||||
**1) python-dotenv로 자동 로드 (권장)**
|
||||
|
||||
```bash
|
||||
pip install python-dotenv
|
||||
```
|
||||
|
||||
예제 스크립트는 `python-dotenv`가 설치되어 있으면 시작 시 자동으로 `.env`를 읽어 `os.environ`에 채워 넣는다(설치돼 있지 않으면 조용히 건너뛴다). 직접 스크립트를 작성할 때도 아래처럼 최상단에서 호출하면 된다.
|
||||
|
||||
```python
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
import os
|
||||
app_key = os.environ.get("APP_KEY")
|
||||
app_secret = os.environ.get("APP_SECRET")
|
||||
```
|
||||
|
||||
**2) 셸에서 직접 환경변수 설정**
|
||||
|
||||
```powershell
|
||||
# PowerShell
|
||||
$env:APP_KEY = "YOUR_APP_KEY"
|
||||
$env:APP_SECRET = "YOUR_APP_SECRET"
|
||||
python example.py
|
||||
```
|
||||
|
||||
```bash
|
||||
# bash
|
||||
export APP_KEY="YOUR_APP_KEY"
|
||||
export APP_SECRET="YOUR_APP_SECRET"
|
||||
python example.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
`finestock`은 **파사드 패턴(Facade Pattern)**과 **인터페이스 분리 원칙(ISP)**을 결합하여 설계되었습니다.
|
||||
@@ -77,6 +137,7 @@ if isinstance(api, RealtimeProvider):
|
||||
|
||||
# 3. 데이터 수신 (비동기 루프 실행 필요)
|
||||
# ... (자세한 예제는 example_v1.py 참조)
|
||||
```
|
||||
|
||||
### 3. 타입 힌팅 활용 (Type Hinting)
|
||||
|
||||
@@ -94,5 +155,4 @@ market_api: MarketDataProvider = full_api
|
||||
# market_api. (여기서 get_ohlcv 등만 보임)
|
||||
df = market_api.get_ohlcv("005930")
|
||||
```
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# 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 <token>`이 포함되어 있어 `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 정비 |
|
||||
@@ -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`는 국내주식 중심 인터페이스라 해외/파생 자산군의 필드(외화, 증거금 등)를 그대로 담기 어렵다.
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import os
|
||||
import sys
|
||||
import finestock
|
||||
from finestock import APIProvider
|
||||
|
||||
try:
|
||||
# 선택 의존성: 설치돼 있으면 .env 파일을 자동으로 읽어 os.environ에 채워준다.
|
||||
# pip install python-dotenv
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
#from loguru import logger
|
||||
finestock.print_version_info()
|
||||
#logging.basicConfig(level=logging.DEBUG)
|
||||
#logger.add(sys.stdout, level="DEBUG")
|
||||
|
||||
#print(logger._core.handlers) # 내부 구조 접근이지만 확인용으로는 유용
|
||||
#exit()
|
||||
|
||||
# 실키/토큰은 절대 코드에 하드코딩하지 말 것. 환경변수로 주입한다.
|
||||
# 예 (PowerShell): $env:APP_KEY="..."; $env:APP_SECRET="..."
|
||||
app_key = os.environ.get("APP_KEY", "YOUR_APP_KEY")
|
||||
app_secret = os.environ.get("APP_SECRET", "YOUR_APP_SECRET")
|
||||
account_num = os.environ.get("ACCOUNT_NUM", "YOUR_ACCOUNT_NUM")
|
||||
account_num_sub = os.environ.get("ACCOUNT_NUM_SUB", "01")
|
||||
access_token = os.environ.get("ACCESS_TOKEN", "")
|
||||
|
||||
api_type = "KISV"
|
||||
api = finestock.APIFactory.create_api(APIProvider.LS)
|
||||
|
||||
api.set_oauth_info(app_key, app_secret)
|
||||
|
||||
login = False
|
||||
if login:
|
||||
api.oauth()
|
||||
print(api.access_token)
|
||||
else:
|
||||
api.headers['authorization'] = f"Bearer {access_token}"
|
||||
|
||||
#list = api.get_condition_list("shinalok")
|
||||
#print(list)
|
||||
#list = api.get_condition_price("shinalok0000")
|
||||
#print(list)
|
||||
# etf_list = ["069500", "229200", "292190", "156080"]
|
||||
# prices = api.get_multiple_ohlcv(etf_list)
|
||||
# print(prices)
|
||||
# exit()
|
||||
#api.set_account_info(account_num, account_num_sub)
|
||||
# ohlcvs = api.get_ohlcv("233740")
|
||||
# print(ohlcvs)
|
||||
# exit()
|
||||
#ohlcvs = api.get_index_min("405", "20250514")
|
||||
ohlcvs = api.get_ohlcv_min("005930", "20250515")
|
||||
print(ohlcvs)
|
||||
print(len(ohlcvs))
|
||||
exit()
|
||||
|
||||
|
||||
acc = api.get_balance()
|
||||
print(acc)
|
||||
|
||||
#res = api.do_order("228790", finestock.ORDER_FLAG.BUY, 0, 1)
|
||||
#print(res)
|
||||
|
||||
exit()
|
||||
orderbook = api.get_orderbook("233740")
|
||||
print(orderbook)
|
||||
#ohlcvs = api.get_ohlcv("233740", "20240806", "20240806")
|
||||
ohlcvs = api.get_ohlcv("233740")
|
||||
print(ohlcvs)
|
||||
exit()
|
||||
|
||||
'''
|
||||
ohlcvs = api.get_ohlcv("005930", "20200101", "20240328")
|
||||
for price in ohlcvs:
|
||||
print(price.workday)
|
||||
exit()
|
||||
'''
|
||||
api.set_account_info(account_num, account_num_sub)
|
||||
acc = api.get_balance()
|
||||
print(acc)
|
||||
exit()
|
||||
|
||||
#ohlcvs = api.get_index("405", "20200101", "20240328")
|
||||
ohlcvs = api.get_index("405", "20240802", "20240802")
|
||||
print(ohlcvs)
|
||||
print(len(ohlcvs))
|
||||
print(ohlcvs)
|
||||
for price in ohlcvs:
|
||||
print(price.workday)
|
||||
exit()
|
||||
#Ebest만 사용 가능
|
||||
#list = api.get_index_list()
|
||||
#print(list)
|
||||
#exit()
|
||||
#res = api.do_order("004410", finestock.ORDER_FLAG.SELL, 176, 1)
|
||||
#print(res)
|
||||
#res = api.do_order_cancle("19441", "004410", 1)
|
||||
#print(res)
|
||||
res = api.get_order_status("004410")
|
||||
print(res)
|
||||
exit()
|
||||
res = api.do_order("004410", finestock.ORDER_FLAG.BUY, 170, 1)
|
||||
print(res)
|
||||
exit()
|
||||
|
||||
list = api.get_stock_list()
|
||||
print(api.stock_master)
|
||||
print(list)
|
||||
print(len(list))
|
||||
#print(list.keys())
|
||||
exit()
|
||||
|
||||
#주식 ohlcv 가져오기
|
||||
ohlcvs = api.get_ohlcv("005930", "20240101", "20240328")
|
||||
print(ohlcvs)
|
||||
logger.debug("Hihi")
|
||||
|
||||
#지수정보 가져오기
|
||||
#index = api.get_index("3003", "20240101", "20240328") #LS: 405, KIS: 3003
|
||||
#print(index)
|
||||
|
||||
#계좌정보 가져오기
|
||||
api.set_account_info(account_num, account_num_sub)
|
||||
acc = api.get_balance()
|
||||
print(acc)
|
||||
|
||||
|
||||
#주문
|
||||
#res = api.do_order("004410", finestock.ORDER_FLAG.BUY, 170, 1)
|
||||
#print(res)
|
||||
|
||||
#주문상태 확인
|
||||
res = api.get_order_status("004410")
|
||||
print(res)
|
||||
|
||||
#주문취소
|
||||
#res = api.do_order_cancle("13889", "004410", 1)
|
||||
#print(res)
|
||||
|
||||
|
||||
exit()
|
||||
#ohlcvs = api.get_ohlcv("005930", "20240101", "20240328")
|
||||
#print(ohlcvs)
|
||||
|
||||
#api.get_index_list()
|
||||
#api.get_ohlcv("005930", count=20)
|
||||
#ohlcvs = api.get_index("3003", "20240101", "20240328")
|
||||
#print(ohlcvs)
|
||||
|
||||
|
||||
#orderbook = api.get_orderbook("005930")
|
||||
#print(orderbook)
|
||||
#res = api.do_order("004410", ORDER.BUY, 150, 1)
|
||||
#res = api.do_order_cancle("12788", "004410", 1)
|
||||
@@ -0,0 +1,62 @@
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
|
||||
from finestock import APIProvider
|
||||
|
||||
try:
|
||||
# 선택 의존성: 설치돼 있으면 .env 파일을 자동으로 읽어 os.environ에 채워준다.
|
||||
# pip install python-dotenv
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
import finestock
|
||||
import asyncio
|
||||
from loguru import logger
|
||||
import sys
|
||||
logger.add(sys.stdout, level="DEBUG")
|
||||
finestock.print_version_info()
|
||||
|
||||
data_queue = queue.Queue()
|
||||
data_condition = threading.Condition()
|
||||
|
||||
# 실키/토큰은 절대 코드에 하드코딩하지 말 것. 환경변수로 주입한다.
|
||||
# 예 (PowerShell): $env:APP_KEY="..."; $env:APP_SECRET="..."
|
||||
app_key = os.environ.get("APP_KEY", "YOUR_APP_KEY")
|
||||
app_secret = os.environ.get("APP_SECRET", "YOUR_APP_SECRET")
|
||||
access_token = os.environ.get("ACCESS_TOKEN", "")
|
||||
|
||||
api_type = APIProvider.LSV
|
||||
api = finestock.APIFactory.create_api(api_type)
|
||||
|
||||
api.set_oauth_info(app_key, app_secret)
|
||||
|
||||
login = False
|
||||
if login:
|
||||
api.oauth()
|
||||
print(api.access_token)
|
||||
else:
|
||||
api.access_token = access_token
|
||||
api.headers['authorization'] = f"Bearer {access_token}"
|
||||
|
||||
api.set_queue(data_queue, data_condition)
|
||||
|
||||
async def main():
|
||||
await api.connect()
|
||||
#await api.recv_orderbook("005930")
|
||||
#await api.recv_price("005930")
|
||||
#await api.recv_orderbook("082850")
|
||||
#await api.recv_price("082850")
|
||||
#await api.recv_price("233740")
|
||||
|
||||
#await api.recv_index("405") #코스닥 150: 405
|
||||
await api.recv_order_status()
|
||||
|
||||
await api.run()
|
||||
|
||||
asyncio.run(main())
|
||||
#loop = asyncio.get_event_loop()
|
||||
#loop.run_until_complete(main())
|
||||
#loop.close()
|
||||
@@ -0,0 +1,98 @@
|
||||
import asyncio
|
||||
import queue
|
||||
import sys
|
||||
from loguru import logger
|
||||
|
||||
import finestock
|
||||
from finestock import APIProvider
|
||||
from finestock.comm.api_interface import AuthenticationProvider, RealtimeProvider
|
||||
|
||||
# Configure logging
|
||||
logger.remove()
|
||||
logger.add(sys.stdout, level="DEBUG")
|
||||
|
||||
async def consume_data(data_queue: queue.Queue):
|
||||
"""
|
||||
Background task to consume data from the queue asynchronously.
|
||||
"""
|
||||
logger.info("Data consumer started...")
|
||||
while True:
|
||||
try:
|
||||
if not data_queue.empty():
|
||||
data = data_queue.get_nowait()
|
||||
print(f"Received Realtime Data: {data}")
|
||||
else:
|
||||
await asyncio.sleep(0.1) # Yield control
|
||||
except Exception as e:
|
||||
logger.error(f"Consumer Error: {e}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def main():
|
||||
finestock.print_version_info()
|
||||
|
||||
# 1. Create API Instance
|
||||
api = finestock.create_api(APIProvider.KIWOOM)
|
||||
|
||||
# 2. Setup Authentication
|
||||
# Kiwoom requires app key/secret?
|
||||
# Based on kiwoom.py:
|
||||
# self.app_key = None
|
||||
# self.app_secret = None
|
||||
# OAuth uses them.
|
||||
# I should use the keys if I have them or reuse from current context if needed.
|
||||
# example_v1.py used:
|
||||
# api.set_oauth_info("RSvh1vtzHj8Z5F15Ee7_rJ5znHuAKOF6A8v90TEkyBk", "_7wuGnkD-Crf1TJKErJPYkQp7AVuh_gVOPgQ2Uyyh7k")
|
||||
|
||||
auth_api: AuthenticationProvider = api
|
||||
if isinstance(auth_api, AuthenticationProvider):
|
||||
APP_KEY = "RSvh1vtzHj8Z5F15Ee7_rJ5znHuAKOF6A8v90TEkyBk"
|
||||
APP_SECRET = "_7wuGnkD-Crf1TJKErJPYkQp7AVuh_gVOPgQ2Uyyh7k"
|
||||
auth_api.set_oauth_info(APP_KEY, APP_SECRET)
|
||||
token = auth_api.oauth()
|
||||
logger.info(f"Logged in.")
|
||||
|
||||
# 3. Setup Realtime Data (Async)
|
||||
realtime_api: RealtimeProvider = api
|
||||
if isinstance(realtime_api, RealtimeProvider):
|
||||
# Create Queue for data
|
||||
data_queue = queue.Queue()
|
||||
realtime_api.set_data_queue(data_queue)
|
||||
|
||||
# Connect to WebSocket
|
||||
logger.info("Connecting to WebSocket...")
|
||||
await realtime_api.connect()
|
||||
|
||||
# Login is handled in connect
|
||||
|
||||
|
||||
# Subscribe to Realtime Prices
|
||||
# Ex: Samsung Electronics (005930)
|
||||
logger.info("Subscribing to 005930...")
|
||||
await realtime_api.recv_price("005930")
|
||||
|
||||
# Subscribe to Realtime Index
|
||||
# Ex: KOSPI (001)
|
||||
logger.info("Subscribing to KOSPI (001)...")
|
||||
await realtime_api.recv_index("001")
|
||||
|
||||
# Start the data consumer task
|
||||
consumer_task = asyncio.create_task(consume_data(data_queue))
|
||||
|
||||
# Start the API run loop
|
||||
logger.info("Starting API Event Loop...")
|
||||
|
||||
# Run for 10 seconds then exit for verification
|
||||
try:
|
||||
await asyncio.wait_for(realtime_api.run(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
logger.info("Test finished (timeout)")
|
||||
|
||||
# Cleanup
|
||||
consumer_task.cancel()
|
||||
await realtime_api.disconnect()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Terminated by user")
|
||||
@@ -0,0 +1,103 @@
|
||||
import asyncio
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
from loguru import logger
|
||||
|
||||
import finestock
|
||||
from finestock import APIProvider
|
||||
from finestock.comm.api_interface import AuthenticationProvider, RealtimeProvider
|
||||
|
||||
try:
|
||||
# 선택 의존성: 설치돼 있으면 .env 파일을 자동으로 읽어 os.environ에 채워준다.
|
||||
# pip install python-dotenv
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Configure logging
|
||||
logger.remove()
|
||||
logger.add(sys.stdout, level="DEBUG")
|
||||
|
||||
|
||||
async def consume_data(data_queue: queue.Queue):
|
||||
"""
|
||||
Background task to consume data from the queue asynchronously.
|
||||
"""
|
||||
logger.info("Data consumer started...")
|
||||
while True:
|
||||
try:
|
||||
if not data_queue.empty():
|
||||
data = data_queue.get_nowait()
|
||||
print(f"Received Realtime Data: {data}")
|
||||
else:
|
||||
await asyncio.sleep(0.1) # Yield control
|
||||
except Exception as e:
|
||||
logger.error(f"Consumer Error: {e}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def main():
|
||||
finestock.print_version_info()
|
||||
|
||||
# 1. Create API Instance
|
||||
# 모의투자로 테스트하려면 APIProvider.NHV를 사용한다. 단, 접근토큰발급(oauth())은
|
||||
# NH·NHV 모두 항상 운영 도메인에서만 발급된다(모의투자 계좌로 거래하더라도 토큰은 동일).
|
||||
api = finestock.create_api(APIProvider.NH)
|
||||
|
||||
# 2. Setup Authentication
|
||||
# 실키/시크릿은 절대 코드에 하드코딩하지 말 것. 환경변수로 주입한다.
|
||||
# 예 (PowerShell): $env:APP_KEY="..."; $env:APP_SECRET="..."
|
||||
auth_api: AuthenticationProvider = api
|
||||
if isinstance(auth_api, AuthenticationProvider):
|
||||
app_key = os.environ.get("APP_KEY", "YOUR_APP_KEY")
|
||||
app_secret = os.environ.get("APP_SECRET", "YOUR_APP_SECRET")
|
||||
auth_api.set_oauth_info(app_key, app_secret)
|
||||
auth_api.oauth()
|
||||
logger.info("Logged in.")
|
||||
|
||||
# 3. Setup Realtime Data (Async)
|
||||
realtime_api: RealtimeProvider = api
|
||||
if isinstance(realtime_api, RealtimeProvider):
|
||||
# Create Queue for data
|
||||
data_queue = queue.Queue()
|
||||
realtime_api.set_data_queue(data_queue)
|
||||
|
||||
# Connect to WebSocket
|
||||
logger.info("Connecting to WebSocket...")
|
||||
await realtime_api.connect()
|
||||
|
||||
# Subscribe to Realtime Price (체결가) — 삼성전자(005930)
|
||||
logger.info("Subscribing to 005930 price...")
|
||||
await realtime_api.recv_price("005930")
|
||||
|
||||
# Subscribe to Realtime Orderbook (호가) — 삼성전자(005930)
|
||||
logger.info("Subscribing to 005930 orderbook...")
|
||||
await realtime_api.recv_orderbook("005930")
|
||||
|
||||
# 참고: NH krstock API에는 지수 실시간 채널이 없어 recv_index()는 stub이다
|
||||
# (호출하면 "not supported" 로그만 남기고 아무것도 구독하지 않는다).
|
||||
|
||||
# Start the data consumer task
|
||||
consumer_task = asyncio.create_task(consume_data(data_queue))
|
||||
|
||||
# Start the API run loop
|
||||
logger.info("Starting API Event Loop...")
|
||||
|
||||
# Run for 30 seconds then exit for verification
|
||||
try:
|
||||
await asyncio.wait_for(realtime_api.run(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
logger.info("Test finished (timeout)")
|
||||
|
||||
# Cleanup
|
||||
consumer_task.cancel()
|
||||
await realtime_api.disconnect()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Terminated by user")
|
||||
@@ -0,0 +1,120 @@
|
||||
import asyncio
|
||||
import queue
|
||||
import sys
|
||||
from loguru import logger
|
||||
|
||||
import finestock
|
||||
from finestock import APIProvider
|
||||
from finestock.comm.api_interface import AuthenticationProvider, RealtimeProvider
|
||||
|
||||
# Configure logging
|
||||
logger.remove()
|
||||
logger.add(sys.stdout, level="DEBUG")
|
||||
|
||||
async def consume_data(data_queue: queue.Queue):
|
||||
"""
|
||||
Background task to consume data from the queue asynchronously.
|
||||
"""
|
||||
logger.info("Data consumer started...")
|
||||
while True:
|
||||
try:
|
||||
# Since refined queue is thread-safe standard queue, we can just poll it
|
||||
# In a real async app, you might use asyncio.Queue for await q.get()
|
||||
# But here we are injecting a standard queue as per interface.
|
||||
# To avoid blocking the loop, we use run_in_executor or just poll with sleep
|
||||
|
||||
if not data_queue.empty():
|
||||
data = data_queue.get_nowait()
|
||||
# Type hinting can be used here if data structure is known
|
||||
if isinstance(data, finestock.Price):
|
||||
type_str = "Index" if data.code == "001" else "Price/Trade" # Simple heuristic
|
||||
print(f"[{type_str}] Code: {data.code}, Time: {data.time}, Current: {data.close}, Vol: {data.volume}")
|
||||
elif isinstance(data, finestock.OrderBook):
|
||||
print(data)
|
||||
print(f"[OrderBook] Code: {data.code}, Buy: {data.total_buy}, Sell: {data.total_sell}")
|
||||
if data.buy: print(f" Best Buy: {data.buy[0].price} ({data.buy[0].qty})")
|
||||
if data.sell: print(f" Best Sell: {data.sell[0].price} ({data.sell[0].qty})")
|
||||
elif isinstance(data, finestock.Trade):
|
||||
print(f"[Order Status] Code: {data.code}, Flag: {data.trade_flag}, Order: {data.order_flag}, Qty: {data.qty}")
|
||||
else:
|
||||
print(f"Received Data: {data}")
|
||||
else:
|
||||
await asyncio.sleep(0.1) # Yield control
|
||||
except Exception as e:
|
||||
logger.error(f"Consumer Error: {e}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def main():
|
||||
finestock.print_version_info()
|
||||
|
||||
# 0. Select Provider
|
||||
# TARGET_PROVIDER = APIProvider.LSV
|
||||
TARGET_PROVIDER = APIProvider.KIWOOMV
|
||||
|
||||
print(f"Selected Provider: {TARGET_PROVIDER.name}")
|
||||
|
||||
# 1. Create API Instance
|
||||
api = finestock.create_api(TARGET_PROVIDER)
|
||||
|
||||
# 2. Setup Authentication
|
||||
auth_api: AuthenticationProvider = api
|
||||
if isinstance(auth_api, AuthenticationProvider):
|
||||
if TARGET_PROVIDER == APIProvider.LSV:
|
||||
# LSV Keys
|
||||
APP_KEY = "PSgjLpp90XLhWexJYGL0P36oyzM3ne5kjhDW"
|
||||
APP_SECRET = "htA3h9kULFPD75vtHAt0JaXyYAjsbf0b"
|
||||
elif TARGET_PROVIDER == APIProvider.KIWOOM:
|
||||
# Kiwoom Keys (Test)
|
||||
APP_KEY = "RSvh1vtzHj8Z5F15Ee7_rJ5znHuAKOF6A8v90TEkyBk"
|
||||
APP_SECRET = "_7wuGnkD-Crf1TJKErJPYkQp7AVuh_gVOPgQ2Uyyh7k"
|
||||
elif TARGET_PROVIDER == APIProvider.KIWOOMV:
|
||||
APP_KEY = "5-7V0r97uwlzmPNFa_FnwFEZtnyhFAZ1qlnDXQH2rX4"
|
||||
APP_SECRET = "YOMvV7X1CwwST-_iFmRAPKXO7Wwr4zGGyTPX5gFcAqk"
|
||||
|
||||
auth_api.set_oauth_info(APP_KEY, APP_SECRET)
|
||||
token = auth_api.oauth()
|
||||
print(f"Logged in with token: {token}")
|
||||
|
||||
# 3. Setup Realtime Data (Async)
|
||||
realtime_api: RealtimeProvider = api
|
||||
if isinstance(realtime_api, RealtimeProvider):
|
||||
# Create Queue for data
|
||||
data_queue = queue.Queue()
|
||||
realtime_api.set_data_queue(data_queue)
|
||||
|
||||
# Connect to WebSocket
|
||||
logger.info("Connecting to WebSocket...")
|
||||
await realtime_api.connect()
|
||||
|
||||
# Subscribe to Realtime Prices
|
||||
# Subscribe to Realtime Prices, Orderbook, and Trades
|
||||
# Ex: Samsung Electronics (005930)
|
||||
logger.info("Subscribing to 005930 (Price, Orderbook, Trade)...")
|
||||
#await realtime_api.recv_price("005930") #완료
|
||||
#await realtime_api.recv_orderbook("005930") #완료
|
||||
#await realtime_api.recv_trade("005930") # Same as price (0B)
|
||||
|
||||
logger.info("Subscribing to Order Status (00)...")
|
||||
await realtime_api.recv_order_status()
|
||||
|
||||
# Subscribe to Index
|
||||
# Ex: KOSPI (001)
|
||||
logger.info("Subscribing to KOSPI Index (001)...")
|
||||
#await realtime_api.recv_index("001") #완료
|
||||
|
||||
# Start the data consumer task
|
||||
consumer_task = asyncio.create_task(consume_data(data_queue))
|
||||
|
||||
# Start the API run loop (This blocks until disconnected)
|
||||
logger.info("Starting API Event Loop...")
|
||||
|
||||
await realtime_api.run()
|
||||
|
||||
# Cleanup (if loop breaks)
|
||||
consumer_task.cancel()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Terminated by user")
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import os
|
||||
import sys
|
||||
import finestock
|
||||
from finestock import APIProvider
|
||||
|
||||
try:
|
||||
# 선택 의존성: 설치돼 있으면 .env 파일을 자동으로 읽어 os.environ에 채워준다.
|
||||
# pip install python-dotenv
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
#from loguru import logger
|
||||
finestock.print_version_info()
|
||||
#logging.basicConfig(level=logging.DEBUG)
|
||||
#logger.add(sys.stdout, level="DEBUG")
|
||||
|
||||
#print(logger._core.handlers) # 내부 구조 접근이지만 확인용으로는 유용
|
||||
#exit()
|
||||
|
||||
# 실키/토큰은 절대 코드에 하드코딩하지 말 것. 환경변수로 주입한다.
|
||||
# 예 (PowerShell): $env:APP_KEY="..."; $env:APP_SECRET="..."
|
||||
app_key = os.environ.get("APP_KEY", "YOUR_APP_KEY")
|
||||
app_secret = os.environ.get("APP_SECRET", "YOUR_APP_SECRET")
|
||||
account_num = os.environ.get("ACCOUNT_NUM", "YOUR_ACCOUNT_NUM")
|
||||
account_num_sub = os.environ.get("ACCOUNT_NUM_SUB", "01")
|
||||
access_token = os.environ.get("ACCESS_TOKEN", "")
|
||||
|
||||
api = finestock.APIFactory.create_api(APIProvider.KIS)
|
||||
api.set_oauth_info(app_key, app_secret)
|
||||
|
||||
login = False
|
||||
if login:
|
||||
api.oauth()
|
||||
print(api.access_token)
|
||||
else:
|
||||
api.headers['authorization'] = f"Bearer {access_token}"
|
||||
|
||||
res = api.get_price("329180")
|
||||
print(res)
|
||||
res = api.get_ohlcv("329180", "20260819", "20260820")
|
||||
print(res)
|
||||
exit()
|
||||
#exit()
|
||||
#list = api.get_condition_list("shinalok")
|
||||
#print(list)
|
||||
#list = api.get_condition_price("shinalok0000")
|
||||
#print(list)
|
||||
# etf_list = ["069500", "229200", "292190", "156080"]
|
||||
# prices = api.get_multiple_ohlcv(etf_list)
|
||||
# print(prices)
|
||||
# exit()
|
||||
#api.set_account_info(account_num, account_num_sub)
|
||||
# ohlcvs = api.get_ohlcv("233740")
|
||||
# print(ohlcvs)
|
||||
# exit()
|
||||
#ohlcvs = api.get_index_min("405", "20250514")
|
||||
ohlcvs = api.get_ohlcv_min("005930", "20250515")
|
||||
print(ohlcvs)
|
||||
print(len(ohlcvs))
|
||||
exit()
|
||||
|
||||
|
||||
acc = api.get_balance()
|
||||
print(acc)
|
||||
|
||||
#res = api.do_order("228790", finestock.ORDER_FLAG.BUY, 0, 1)
|
||||
#print(res)
|
||||
|
||||
exit()
|
||||
orderbook = api.get_orderbook("233740")
|
||||
print(orderbook)
|
||||
#ohlcvs = api.get_ohlcv("233740", "20240806", "20240806")
|
||||
ohlcvs = api.get_ohlcv("233740")
|
||||
print(ohlcvs)
|
||||
exit()
|
||||
|
||||
'''
|
||||
ohlcvs = api.get_ohlcv("005930", "20200101", "20240328")
|
||||
for price in ohlcvs:
|
||||
print(price.workday)
|
||||
exit()
|
||||
'''
|
||||
api.set_account_info(account_num, account_num_sub)
|
||||
acc = api.get_balance()
|
||||
print(acc)
|
||||
exit()
|
||||
|
||||
#ohlcvs = api.get_index("405", "20200101", "20240328")
|
||||
ohlcvs = api.get_index("405", "20240802", "20240802")
|
||||
print(ohlcvs)
|
||||
print(len(ohlcvs))
|
||||
print(ohlcvs)
|
||||
for price in ohlcvs:
|
||||
print(price.workday)
|
||||
exit()
|
||||
#Ebest만 사용 가능
|
||||
#list = api.get_index_list()
|
||||
#print(list)
|
||||
#exit()
|
||||
#res = api.do_order("004410", finestock.ORDER_FLAG.SELL, 176, 1)
|
||||
#print(res)
|
||||
#res = api.do_order_cancle("19441", "004410", 1)
|
||||
#print(res)
|
||||
res = api.get_order_status("004410")
|
||||
print(res)
|
||||
exit()
|
||||
res = api.do_order("004410", finestock.ORDER_FLAG.BUY, 170, 1)
|
||||
print(res)
|
||||
exit()
|
||||
|
||||
list = api.get_stock_list()
|
||||
print(api.stock_master)
|
||||
print(list)
|
||||
print(len(list))
|
||||
#print(list.keys())
|
||||
exit()
|
||||
|
||||
#주식 ohlcv 가져오기
|
||||
ohlcvs = api.get_ohlcv("005930", "20240101", "20240328")
|
||||
print(ohlcvs)
|
||||
logger.debug("Hihi")
|
||||
|
||||
#지수정보 가져오기
|
||||
#index = api.get_index("3003", "20240101", "20240328") #LS: 405, KIS: 3003
|
||||
#print(index)
|
||||
|
||||
#계좌정보 가져오기
|
||||
api.set_account_info(account_num, account_num_sub)
|
||||
acc = api.get_balance()
|
||||
print(acc)
|
||||
|
||||
|
||||
#주문
|
||||
#res = api.do_order("004410", finestock.ORDER_FLAG.BUY, 170, 1)
|
||||
#print(res)
|
||||
|
||||
#주문상태 확인
|
||||
res = api.get_order_status("004410")
|
||||
print(res)
|
||||
|
||||
#주문취소
|
||||
#res = api.do_order_cancle("13889", "004410", 1)
|
||||
#print(res)
|
||||
|
||||
|
||||
exit()
|
||||
#ohlcvs = api.get_ohlcv("005930", "20240101", "20240328")
|
||||
#print(ohlcvs)
|
||||
|
||||
#api.get_index_list()
|
||||
#api.get_ohlcv("005930", count=20)
|
||||
#ohlcvs = api.get_index("3003", "20240101", "20240328")
|
||||
#print(ohlcvs)
|
||||
|
||||
|
||||
#orderbook = api.get_orderbook("005930")
|
||||
#print(orderbook)
|
||||
#res = api.do_order("004410", ORDER.BUY, 150, 1)
|
||||
#res = api.do_order_cancle("12788", "004410", 1)
|
||||
@@ -0,0 +1,64 @@
|
||||
import os
|
||||
import finestock
|
||||
from finestock import APIProvider
|
||||
|
||||
try:
|
||||
# 선택 의존성: 설치돼 있으면 .env 파일을 자동으로 읽어 os.environ에 채워준다.
|
||||
# pip install python-dotenv
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
finestock.print_version_info()
|
||||
|
||||
# 실키/토큰은 절대 코드에 하드코딩하지 말 것. 환경변수로 주입한다.
|
||||
# 예 (PowerShell): $env:APP_KEY="..."; $env:APP_SECRET="..."
|
||||
app_key = os.environ.get("APP_KEY", "YOUR_APP_KEY")
|
||||
app_secret = os.environ.get("APP_SECRET", "YOUR_APP_SECRET")
|
||||
access_token = os.environ.get("ACCESS_TOKEN", "")
|
||||
|
||||
# 모의투자로 테스트하려면 APIProvider.NHV를 사용한다. 단, 접근토큰발급(oauth())은
|
||||
# NH·NHV 모두 항상 운영 도메인에서만 발급된다(모의투자 계좌로 거래하더라도 토큰은 동일).
|
||||
api = finestock.APIFactory.create_api(APIProvider.NH)
|
||||
api.set_oauth_info(app_key, app_secret)
|
||||
|
||||
login = False
|
||||
if login:
|
||||
api.oauth()
|
||||
print(api.access_token)
|
||||
else:
|
||||
api.headers['authorization'] = f"Bearer {access_token}"
|
||||
|
||||
# 1) 계좌 목록 조회 — /n2/acctinfo. acct_type이 01/02면 운영(NH) 전용, 03이면 모의투자(NHV) 전용.
|
||||
accounts = api.get_account_list()
|
||||
print(accounts)
|
||||
|
||||
# 계좌목록에서 사용할 계좌를 골라 세팅한다. act_no는 11자리 단일 값이라 sub는 비워둔다.
|
||||
account_num = os.environ.get("ACCOUNT_NUM", "YOUR_ACCOUNT_NUM")
|
||||
#account_num_sub = os.environ.get("ACCOUNT_NUM_SUB", "YOUR_ACCOUNT_NUM")
|
||||
api.set_account_info(account_num, "")
|
||||
|
||||
'''
|
||||
# 2) 시세 조회
|
||||
price = api.get_price("005930")
|
||||
print(price)
|
||||
|
||||
ohlcvs = api.get_ohlcv("005930", "20260701", "20260821")
|
||||
print(len(ohlcvs), ohlcvs[:3] if ohlcvs else ohlcvs)
|
||||
|
||||
orderbook = api.get_orderbook("005930")
|
||||
print(orderbook)
|
||||
'''
|
||||
# 3) 계좌/잔고 조회
|
||||
acc = api.get_balance()
|
||||
print(acc)
|
||||
if acc:
|
||||
print(len(acc.hold), acc.hold[:3] if acc.hold else acc.hold)
|
||||
|
||||
# 4) 주문 (실제 체결되니 운영 도메인에서는 주의 — 테스트는 APIProvider.NHV 권장)
|
||||
# res = api.do_order("005930", finestock.ORDER_FLAG.BUY, 0, 1) # price=0 -> 시장가
|
||||
# print(res)
|
||||
# if res:
|
||||
# cancel = api.do_order_cancel(res.order_num, "005930", 0) # qty<=0 -> 전량취소
|
||||
# print(cancel)
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import sys
|
||||
from loguru import logger
|
||||
|
||||
import finestock
|
||||
from finestock import APIProvider
|
||||
from finestock.comm.api_interface import AuthenticationProvider, MarketDataProvider, TradingProvider, AccountProvider
|
||||
|
||||
# Configure logging
|
||||
logger.remove()
|
||||
logger.add(sys.stdout, level="INFO")
|
||||
|
||||
|
||||
def main():
|
||||
finestock.print_version_info()
|
||||
|
||||
# 1. Create API Instance
|
||||
# The 'api' object implements multiple interfaces (Authentication, MarketData, Realtime, etc.)
|
||||
api = finestock.create_api(APIProvider.KIWOOMV)
|
||||
|
||||
# 2. Setup Authentication (Sync)
|
||||
# Type Hinting: View 'api' as AuthenticationProvider to see only auth-related methods
|
||||
auth_api: AuthenticationProvider = api
|
||||
if isinstance(auth_api, AuthenticationProvider):
|
||||
#APP_KEY = "YOUR_APP_KEY"
|
||||
#APP_SECRET = "YOUR_APP_SECRET"
|
||||
#APP_KEY = "PSgjLpp90XLhWexJYGL0P36oyzM3ne5kjhDW" #LSV
|
||||
#APP_SECRET = "htA3h9kULFPD75vtHAt0JaXyYAjsbf0b" #LSV
|
||||
#APP_KEY = "RSvh1vtzHj8Z5F15Ee7_rJ5znHuAKOF6A8v90TEkyBk"
|
||||
#APP_SECRET = "_7wuGnkD-Crf1TJKErJPYkQp7AVuh_gVOPgQ2Uyyh7k"
|
||||
APP_KEY = "5-7V0r97uwlzmPNFa_FnwFEZtnyhFAZ1qlnDXQH2rX4"
|
||||
APP_SECRET = "YOMvV7X1CwwST-_iFmRAPKXO7Wwr4zGGyTPX5gFcAqk"
|
||||
auth_api.set_oauth_info(APP_KEY, APP_SECRET)
|
||||
token = auth_api.oauth()
|
||||
print(token)
|
||||
# auth_api.oauth() # Perform actual login
|
||||
# or auth_api.set_access_token("TOKEN")
|
||||
|
||||
# 3. Market Data (Sync)
|
||||
logger.info("Fetching Market Data (Sync)...")
|
||||
# Type Hinting: View 'api' as MarketDataProvider
|
||||
market_api: MarketDataProvider = api
|
||||
if isinstance(market_api, MarketDataProvider):
|
||||
try:
|
||||
# ohlcvs = market_api.get_ohlcv("005930", "20240101", "20240105")
|
||||
# print(f"Received {len(ohlcvs)} records")
|
||||
ohlcv = market_api.get_price("005930")
|
||||
print(f"Received {ohlcv}")
|
||||
exit()
|
||||
|
||||
# Example: Parsing Price objects
|
||||
print(f"{'Date':<10} | {'Close':<10} | {'Volume':<10}")
|
||||
print("-" * 36)
|
||||
|
||||
price: finestock.Price = ohlcv
|
||||
# Accessing fields of the Price dataclass
|
||||
print(f"{price.workday:<10} | {price.close:<10} | {price.volume:<10}")
|
||||
except Exception as e:
|
||||
logger.error(f"Market Data Error: {e}")
|
||||
exit()
|
||||
try:
|
||||
# ohlcvs = market_api.get_ohlcv("005930", "20240101", "20240105")
|
||||
# print(f"Received {len(ohlcvs)} records")
|
||||
ohlcvs = market_api.get_ohlcv("005930", "20240101", "20240105")
|
||||
print(f"Received {len(ohlcvs)} records")
|
||||
|
||||
# Example: Parsing Price objects
|
||||
print(f"{'Date':<10} | {'Close':<10} | {'Volume':<10}")
|
||||
print("-" * 36)
|
||||
for price in ohlcvs:
|
||||
price: finestock.Price = price
|
||||
# Accessing fields of the Price dataclass
|
||||
print(f"{price.workday:<10} | {price.close:<10} | {price.volume:<10}")
|
||||
except Exception as e:
|
||||
logger.error(f"Market Data Error: {e}")
|
||||
|
||||
try:
|
||||
logger.info("Fetching Stock List (KOSPI)...")
|
||||
# info_api = api # InfoProvider
|
||||
if hasattr(market_api, 'get_stock_list'):
|
||||
stocks = market_api.get_stock_list("0") # 0: KOSPI
|
||||
print(f"Received {len(stocks)} stocks for KOSPI")
|
||||
print(f"First 5 stocks:")
|
||||
for s in stocks[:5]:
|
||||
print(s)
|
||||
except Exception as e:
|
||||
logger.error(f"Stock List Error: {e}")
|
||||
|
||||
try:
|
||||
logger.info("Fetching KOSDAQ Index List...")
|
||||
# market_api is typed as MarketDataProvider if using interface, but get_index_list is specific to Kiwoom or extended interface
|
||||
# Since market_api is actually the API instance, we can call it if available
|
||||
if hasattr(market_api, 'get_index_list'):
|
||||
indices = market_api.get_index_list("0") # 0: KOSPI
|
||||
print(f"Received {len(indices)} indices for KOSPI")
|
||||
print(f"First 5 indices:")
|
||||
for idx in indices[:5]:
|
||||
print(f"Code: {idx.code}, Name: {idx.name}, Market: {idx.market}, Group: {idx.group}")
|
||||
except Exception as e:
|
||||
logger.error(f"Index List Error: {e}")
|
||||
|
||||
try:
|
||||
logger.info("Fetching KOSDAQ 150 Index Data...")
|
||||
# KOSDAQ 150 Code: 150
|
||||
ohlcvs = market_api.get_index("150", "20260201", "20260213")
|
||||
print(f"Received {len(ohlcvs)} records for KOSDAQ 150")
|
||||
|
||||
print(f"{'Date':<10} | {'Close':<10} | {'Volume':<10}")
|
||||
print("-" * 36)
|
||||
for price in ohlcvs:
|
||||
price: finestock.Price = price
|
||||
print(f"{price.workday:<10} | {price.close:<10} | {price.volume:<10}")
|
||||
except Exception as e:
|
||||
logger.error(f"KOSDAQ 150 Error: {e}")
|
||||
|
||||
try:
|
||||
logger.info("Fetching Orderbook for Samsung Electronics (005930)...")
|
||||
orderbook = market_api.get_orderbook("005930")
|
||||
if orderbook:
|
||||
print(f"Orderbook for {orderbook.code}")
|
||||
print(f"Total Buy: {orderbook.total_buy}, Total Sell: {orderbook.total_sell}")
|
||||
print("Sell Side (Top 5):")
|
||||
for h in orderbook.sell[:5]:
|
||||
print(f" Price: {h.price}, Qty: {h.qty}")
|
||||
print("Buy Side (Top 5):")
|
||||
for h in orderbook.buy[:5]:
|
||||
print(f" Price: {h.price}, Qty: {h.qty}")
|
||||
else:
|
||||
print("Orderbook empty or failed.")
|
||||
except Exception as e:
|
||||
logger.error(f"Orderbook Error: {e}")
|
||||
|
||||
# 4. Trading (Sync)
|
||||
logger.info("Trading Example...")
|
||||
trading_api: TradingProvider = api
|
||||
if isinstance(trading_api, TradingProvider):
|
||||
try:
|
||||
# Example: Buy Samsung Electronics (005930) 1 share at Market Price
|
||||
# trde_tp 3: Market Price, 0: Limit Price
|
||||
# In do_order, price=0 means Market Price (trde_tp="3")
|
||||
|
||||
# Uncomment to execute
|
||||
# logger.info("Placing Buy Order for 005930 (1 qty, Market Price)...")
|
||||
#result = trading_api.do_order("005930", finestock.ORDER_FLAG.BUY, 0, 1)
|
||||
#result = trading_api.do_order("005930", finestock.ORDER_FLAG.BUY, 173000, 1)
|
||||
#result = trading_api.do_order("005930", finestock.ORDER_FLAG.BUY, 176000, 1)
|
||||
result = trading_api.do_order_cancel("0157069", "005930", 1)
|
||||
#result = trading_api.do_order("005930", finestock.ORDER_FLAG.SELL, 177200, 1)
|
||||
print(f"Order Result: {result}")
|
||||
|
||||
#print("To place an order, uncomment the lines in the script.")
|
||||
|
||||
# Example: Cancel Order
|
||||
# logger.info("Cancelling Order 0000140 for 005930 (1 qty)...")
|
||||
# result = trading_api.do_order_cancel("0000140", "005930", 1)
|
||||
# print(f"Cancel Result: {result}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Trading Error: {e}")
|
||||
|
||||
# 5. Account (Sync)
|
||||
logger.info("Account Example...")
|
||||
account_api: AccountProvider = api
|
||||
if isinstance(account_api, AccountProvider):
|
||||
try:
|
||||
logger.info("Fetching Account Balance...")
|
||||
balance = account_api.get_balance()
|
||||
if balance:
|
||||
print(f"Account: {balance.account_num}")
|
||||
print(f"Deposit: {balance.deposit}")
|
||||
print(f"Next Deposit: {balance.next_deposit}")
|
||||
print(f"Pay Deposit: {balance.pay_deposit}")
|
||||
else:
|
||||
print("Failed to get balance.")
|
||||
|
||||
logger.info("Fetching Account Holdings...")
|
||||
holds = account_api.get_holds()
|
||||
print(f"Received {len(holds)} holdings")
|
||||
if holds:
|
||||
print(f"{'Code':<10} | {'Name':<20} | {'Qty':<10} | {'Eval':<15}")
|
||||
print("-" * 60)
|
||||
for h in holds:
|
||||
print(f"{h.code:<10} | {h.name:<20} | {h.qty:<10} | {h.eval:<15}")
|
||||
else:
|
||||
print("No holdings or failed to fetch.")
|
||||
except Exception as e:
|
||||
logger.error(f"Account Error: {e}")
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -8,6 +8,8 @@ class APIProvider(Enum):
|
||||
KISV = "KISV"
|
||||
KIWOOM = "KIWOOM"
|
||||
KIWOOMV = "KIWOOMV"
|
||||
NH = "NH"
|
||||
NHV = "NHV"
|
||||
|
||||
class APIFactory:
|
||||
@staticmethod
|
||||
@@ -33,5 +35,11 @@ class APIFactory:
|
||||
elif api_provider == APIProvider.KIWOOMV:
|
||||
from .kiwoom import KiwoomV
|
||||
return KiwoomV()
|
||||
elif api_provider == APIProvider.NH:
|
||||
from .nh import Nh
|
||||
return Nh()
|
||||
elif api_provider == APIProvider.NHV:
|
||||
from .nh import NhV
|
||||
return NhV()
|
||||
else:
|
||||
raise ValueError("Unsupported API provider")
|
||||
+21
-2
@@ -20,8 +20,8 @@ class Kis(API):
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret
|
||||
}
|
||||
data = json.dumps(data)
|
||||
return super().oauth(data=data)
|
||||
header = {"Content-Type": "application/json; charset=UTF-8"}
|
||||
return super().oauth(header=header, data=json.dumps(data))
|
||||
|
||||
def approval(self):
|
||||
header = self.headers.copy()
|
||||
@@ -67,6 +67,11 @@ class Kis(API):
|
||||
|
||||
return ohlcvs
|
||||
|
||||
def get_ohlcv_min(self, code, todate="", exchgubun="K", cts_date="", cts_time="", tr_cont_key=""):
|
||||
# TODO: KIS 분봉 조회 TR(FHKST03010200) 미연동. 우선 인터페이스 계약만 충족.
|
||||
print("Kis get_ohlcv_min not supported yet")
|
||||
return []
|
||||
|
||||
def get_index(self, code, frdate=datetime.now().strftime('%Y%m%d'), todate=datetime.now().strftime('%Y%m%d')):
|
||||
header = self.headers.copy()
|
||||
header["tr_id"] = "FHKUP03500100"
|
||||
@@ -88,6 +93,11 @@ class Kis(API):
|
||||
|
||||
return ohlcvs
|
||||
|
||||
def get_index_min(self, code, todate="", cts_date=" ", cts_time="", tr_cont_key=""):
|
||||
# TODO: KIS 지수 분봉 조회 TR 미연동. 우선 인터페이스 계약만 충족.
|
||||
print("Kis get_index_min not supported yet")
|
||||
return []
|
||||
|
||||
def get_orderbook(self, code):
|
||||
header = self.headers.copy()
|
||||
header["tr_id"] = "FHKST01010200"
|
||||
@@ -146,6 +156,10 @@ class Kis(API):
|
||||
return finestock.Account(self.account_num, self.account_num_sub, int(acc["dnca_tot_amt"]), int(acc["nxdy_excc_amt"]),
|
||||
int(acc["prvs_rcdl_excc_amt"]), holds)
|
||||
|
||||
def get_holds(self):
|
||||
balance = self.get_balance()
|
||||
return balance.hold if balance else []
|
||||
|
||||
def do_order(self, code, buy_flag, price, qty):
|
||||
url = f"{self.DOMAIN}/{self.ORDER}"
|
||||
header = self.headers.copy()
|
||||
@@ -180,6 +194,11 @@ class Kis(API):
|
||||
def get_index_list(self):
|
||||
print("Kis not supported")
|
||||
|
||||
def get_stock_list(self, mrkt_tp="0"):
|
||||
# TODO: KIS 종목 리스트 조회 TR 미연동. 우선 인터페이스 계약만 충족.
|
||||
print("Kis get_stock_list not supported yet")
|
||||
return []
|
||||
|
||||
async def connect(self):
|
||||
self.approval()
|
||||
self.ws = await websockets.connect(self.DOMAIN_WS)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from .nh import Nh
|
||||
from .nh_v import NhV
|
||||
@@ -0,0 +1,383 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
import websockets
|
||||
from loguru import logger
|
||||
from websockets.exceptions import ConnectionClosedOK
|
||||
|
||||
import finestock
|
||||
from finestock.comm import API
|
||||
|
||||
|
||||
class Nh(API):
|
||||
"""
|
||||
NH투자증권 나무(Namuh) Open API.
|
||||
|
||||
- 모든 REST 호출은 POST + JSON, 요청은 {"Input_0": {...}}, 응답은
|
||||
rsp_cd/rsp_msg + Output_0(+Output_1...) + message 봉투를 사용한다.
|
||||
- 인증은 Authorization: Bearer {access_token} + x-client-id + x-client-secret 헤더.
|
||||
- 접근토큰발급(oauth2/token)은 모의투자 환경에서도 항상 운영 도메인(OAUTH_DOMAIN)에서만
|
||||
발급된다 — DOMAIN이 모의투자(moapi)로 바뀌는 NhV에서도 이 값은 고정이다.
|
||||
- 계좌번호(act_no)는 /n2/acctinfo 응답의 acct_no(11자리) 하나로 구성되며 별도의
|
||||
계좌상품코드 분리가 없다. set_account_info(account_num, account_num_sub)는 다른
|
||||
브로커와의 인터페이스 호환을 위해 유지하되, account_num_sub는 비워두거나(단일
|
||||
계좌번호를 account_num에 그대로 전달) 필요 시 account_num에 이어붙일 접미사로 쓴다.
|
||||
"""
|
||||
|
||||
RSP_OK = "00000"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.is_run = True
|
||||
print("create Nh Components")
|
||||
|
||||
def __del__(self):
|
||||
logger.debug("Destroy Nh Components")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 인증
|
||||
# ------------------------------------------------------------------
|
||||
def set_oauth_info(self, app_key, app_secret):
|
||||
self.app_key = app_key
|
||||
self.app_secret = app_secret
|
||||
self.headers['x-client-id'] = app_key
|
||||
self.headers['x-client-secret'] = app_secret
|
||||
|
||||
def oauth(self):
|
||||
# 접근토큰발급은 모의투자 미제공 — 항상 운영 도메인(OAUTH_DOMAIN)에서만 발급받는다.
|
||||
url = f"{self.OAUTH_DOMAIN}/{self.OAUTH}"
|
||||
header = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||
data = {
|
||||
"appkey": self.app_key,
|
||||
"appsecretkey": self.app_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": "oob",
|
||||
}
|
||||
response = requests.post(url, headers=header, data=data)
|
||||
try:
|
||||
res = response.json()
|
||||
except Exception:
|
||||
res = response.text
|
||||
|
||||
logger.debug(f"[API: oauth]\n"
|
||||
f"[URL: {url}]\n"
|
||||
f"[header: {header}]\n"
|
||||
f"[param: {data}]\n"
|
||||
f"[response: {res}]")
|
||||
|
||||
if response.status_code == 200 and isinstance(res, dict) and "access_token" in res:
|
||||
self.set_access_token(res['access_token'])
|
||||
return res
|
||||
|
||||
def get_account_list(self):
|
||||
"""/n2/acctinfo — 보유 계좌 목록([{acct_no, acct_type}, ...]) 조회. 인터페이스 외 헬퍼."""
|
||||
res = self._post(self.ACCOUNT_LIST, {})
|
||||
if res.get('rsp_cd') == self.RSP_OK:
|
||||
return res.get('Output_0', [])
|
||||
return []
|
||||
|
||||
def _act_no(self):
|
||||
if self.account_num_sub:
|
||||
return f"{self.account_num}{self.account_num_sub}"
|
||||
return self.account_num
|
||||
|
||||
def _post(self, path, input_0, extra_headers=None):
|
||||
url = f"{self.DOMAIN}/{path}"
|
||||
header = self.headers.copy()
|
||||
if extra_headers:
|
||||
header.update(extra_headers)
|
||||
body = {"Input_0": input_0}
|
||||
response = requests.post(url, headers=header, data=json.dumps(body))
|
||||
res = response.json()
|
||||
self._last_response = response # 연속조회(cts/cts_flag)는 body가 아니라 응답 헤더로 내려온다
|
||||
logger.debug(f"[API: nh]\n"
|
||||
f"[URL: {url}]\n"
|
||||
f"[header: {header}]\n"
|
||||
f"[param: {body}]\n"
|
||||
f"[response: {res}]")
|
||||
return res
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 시세
|
||||
# ------------------------------------------------------------------
|
||||
def get_price(self, code, market_cd="UNT"):
|
||||
input_0 = {"market_cd": market_cd, "iem_cd": code}
|
||||
res = self._post(self.PRICE, input_0)
|
||||
if res.get('rsp_cd') != self.RSP_OK:
|
||||
return None
|
||||
|
||||
data = res.get('Output_0', {})
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
price = float(data['stck_prpr'])
|
||||
return finestock.Price(today, code, price, float(data['stck_oprc']), float(data['stck_hgpr']),
|
||||
float(data['stck_lwpr']), price, int(data['acml_vol']), int(data['acml_tr_pbmn']),
|
||||
time=data.get('hoga_bsop_hour'))
|
||||
|
||||
def get_ohlcv(self, code, frdate="", todate="", market_cd="UNT"):
|
||||
todate = todate or datetime.now().strftime('%Y%m%d')
|
||||
frdate = frdate or todate
|
||||
|
||||
# NH의 기간별시세(period)는 LS/KIS와 달리 연속조회 키가 없다 — edate 기준으로
|
||||
# array_cnt만큼 한 번에 내려오는 값을 받아 frdate~todate로 클라이언트에서 자른다.
|
||||
input_0 = {
|
||||
"market_cd": market_cd,
|
||||
"iem_cd": code,
|
||||
"gubun": "1", # 1:일 2:주 3:월 4:년
|
||||
"edate": todate,
|
||||
"array_cnt": "900",
|
||||
}
|
||||
res = self._post(self.CHART, input_0)
|
||||
|
||||
ohlcvs = []
|
||||
if res.get('rsp_cd') == self.RSP_OK:
|
||||
for row in res.get('Output_1', []):
|
||||
bsop_date = row['bsop_date']
|
||||
if bsop_date < frdate or bsop_date > todate:
|
||||
continue
|
||||
price = float(row['stck_prpr'])
|
||||
ohlcvs.append(finestock.Price(bsop_date, code, price, float(row['stck_oprc']), float(row['stck_hgpr']),
|
||||
float(row['stck_lwpr']), price, int(row['vol']), int(row['tr_pbmn'])))
|
||||
|
||||
ohlcvs.sort(key=lambda p: p.workday)
|
||||
return ohlcvs
|
||||
|
||||
def get_ohlcv_min(self, code, todate="", exchgubun="K", cts_date="", cts_time="", tr_cont_key=""):
|
||||
# NH는 분봉 전용 TR이 없고 기간별시세(period)를 gubun=5(분)로 재사용한다.
|
||||
# cts_date/cts_time/tr_cont_key는 NH가 분봉 연속조회를 지원하지 않아 사용하지
|
||||
# 않는다 — 다른 브로커와의 인터페이스 호환을 위해서만 받아둔다.
|
||||
todate = todate or datetime.now().strftime('%Y%m%d')
|
||||
market_cd = {"K": "KRX", "N": "NXT"}.get(exchgubun, "UNT")
|
||||
today_cls_code = "1" if todate == datetime.now().strftime('%Y%m%d') else "0"
|
||||
|
||||
input_0 = {
|
||||
"market_cd": market_cd,
|
||||
"iem_cd": code,
|
||||
"gubun": "5", # 5:분
|
||||
"xtick": "1", # 1분봉
|
||||
"edate": todate,
|
||||
"array_cnt": "900",
|
||||
"today_cls_code": today_cls_code,
|
||||
}
|
||||
res = self._post(self.CHART, input_0)
|
||||
|
||||
ohlcvs = []
|
||||
if res.get('rsp_cd') == self.RSP_OK:
|
||||
for row in res.get('Output_1', []):
|
||||
price = float(row['stck_prpr'])
|
||||
ohlcvs.append(finestock.Price(row['bsop_date'], code, price, float(row['stck_oprc']), float(row['stck_hgpr']),
|
||||
float(row['stck_lwpr']), price, int(row['vol']), int(row['tr_pbmn']),
|
||||
row.get('bsop_time')))
|
||||
return ohlcvs
|
||||
|
||||
def get_index(self, code, frdate="", todate=""):
|
||||
# krstock(국내주식) API에는 지수 시세 TR이 없다 — 인터페이스 계약만 충족.
|
||||
print("Nh get_index not supported yet")
|
||||
return []
|
||||
|
||||
def get_index_min(self, code, todate="", cts_date=" ", cts_time="", tr_cont_key=""):
|
||||
print("Nh get_index_min not supported yet")
|
||||
return []
|
||||
|
||||
def get_orderbook(self, code, market_cd="UNT"):
|
||||
# NH는 호가 전용 TR이 없고 주식현재가시세(currentPrice) 응답에 10단계 호가가 포함된다.
|
||||
input_0 = {"market_cd": market_cd, "iem_cd": code}
|
||||
res = self._post(self.PRICE, input_0)
|
||||
if res.get('rsp_cd') != self.RSP_OK:
|
||||
return None
|
||||
|
||||
data = res.get('Output_0', {})
|
||||
sells = [finestock.Hoga(int(data[f'askp{i}']), int(data[f'askp_rsqn{i}'])) for i in range(1, 11)]
|
||||
buys = [finestock.Hoga(int(data[f'bidp{i}']), int(data[f'bidp_rsqn{i}'])) for i in range(1, 11)]
|
||||
total_buy = int(data['total_bidp_rsqn'])
|
||||
total_sell = int(data['total_askp_rsqn'])
|
||||
return finestock.OrderBook(code, total_buy, total_sell, buys, sells)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 주문
|
||||
# ------------------------------------------------------------------
|
||||
def do_order(self, code, buy_flag, price, qty, rmt_mkt_cd="KRX"):
|
||||
path = self.ORDER_CASH_BUY if buy_flag == finestock.ORDER_FLAG.BUY else self.ORDER_CASH_SELL
|
||||
nmn_pr_tp_cd = "05" if price == 0 else "01" # 01:보통가(지정가) 05:시장가
|
||||
|
||||
input_0 = {
|
||||
"act_no": self._act_no(),
|
||||
"iem_cd": code,
|
||||
"orr_qty": qty,
|
||||
"orr_pr": price,
|
||||
"nmn_pr_tp_cd": nmn_pr_tp_cd,
|
||||
"orr_cnd_dit_cd": "00", # 00:없음 01:IOC 02:FOK
|
||||
"ssl_nmn_pr_dit_cd": "00", # 00:정상(공매도 아님)
|
||||
"rmt_mkt_cd": rmt_mkt_cd, # SOR/KRX/NXT
|
||||
"sor_mkt_sli_yn": "Y" if rmt_mkt_cd == "SOR" else "N",
|
||||
}
|
||||
res = self._post(path, input_0)
|
||||
if res.get('rsp_cd') != self.RSP_OK:
|
||||
return None
|
||||
|
||||
data = res.get('Output_0', {})
|
||||
return finestock.Order(code, '', price, qty, buy_flag, str(data.get('mkt_orr_no')))
|
||||
|
||||
def do_order_cancel(self, order_num, code, qty):
|
||||
all_pat_dit_cd = "1" if qty <= 0 else "2" # 1:전체(전량) 2:일부(잔량)
|
||||
input_0 = {
|
||||
"act_no": self._act_no(),
|
||||
"org_mkt_orr_no": int(order_num),
|
||||
"all_pat_dit_cd": all_pat_dit_cd,
|
||||
"iem_cd": code,
|
||||
}
|
||||
if all_pat_dit_cd == "2":
|
||||
input_0["cor_qty"] = qty
|
||||
|
||||
res = self._post(self.ORDER_CANCEL, input_0)
|
||||
if res.get('rsp_cd') != self.RSP_OK:
|
||||
return None
|
||||
|
||||
data = res.get('Output_0', {})
|
||||
return finestock.Order(code, '', 0, qty, finestock.ORDER_FLAG.VIEW, str(data.get('mkt_orr_no')))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 계좌
|
||||
# ------------------------------------------------------------------
|
||||
def get_balance(self, cts="", cts_flag=""):
|
||||
input_0 = {
|
||||
"act_no": self._act_no(),
|
||||
"bnc_bse_cd": "1", # 1:주식관련 총 평가(체결기준) 5:주식잔고평가(현재가기준)
|
||||
"ltg_aot_dit_cd": "1", # 1:상장종목 9:전체
|
||||
"aet_bse": "1", # 1:순자산 2:총자산
|
||||
"qut_dit_cd": "UNT", # UNT/KRX/NXT
|
||||
}
|
||||
# 연속조회는 body가 아니라 요청 헤더의 cts(연속거래키)/cts_flag(Y)로 이어받는다.
|
||||
extra_headers = {"cts": cts, "cts_flag": "Y"} if cts_flag == "Y" else None
|
||||
res = self._post(self.BALANCE, input_0, extra_headers)
|
||||
# 이 TR은 "성공"에 해당하는 rsp_cd가 하나로 고정돼 있지 않다 — 실제로 확인된 것만도
|
||||
# '00218'(연속조회 데이터 더 있음, rsp_msg: "계속 조회시 다음(연속조회) 버튼을 누르시기
|
||||
# 바랍니다.")과 '00166'(마지막 페이지, rsp_msg: "조회가 완료되었습니다.") 둘 다 정상
|
||||
# 응답이었다. rsp_cd를 화이트리스트로 고정하는 대신 Output_0가 실제로 왔는지(게이트웨이
|
||||
# 오류는 'IGW...' 코드로 오고 Output_0가 없다)로 성공 여부를 판단한다.
|
||||
if 'Output_0' not in res:
|
||||
return None
|
||||
|
||||
data = res.get('Output_0', {})
|
||||
holds = []
|
||||
for h in res.get('Output_1', []):
|
||||
eal_amt = int(float(h.get('eal_amt') or 0))
|
||||
eal_pls_amt = int(float(h.get('eal_pls_amt') or 0))
|
||||
holds.append(finestock.Hold(h['iem_cd'], h['iem_nm'], int(float(h['phs_pr'])),
|
||||
int(float(h['itg_bnc_qty'])), eal_amt - eal_pls_amt,
|
||||
eal_amt))
|
||||
|
||||
# Output_1은 페이지당 최대 10건 — 응답 헤더에 다음 페이지가 있다는 cts_flag=Y와
|
||||
# 연속거래키(cts)가 내려오면 이어서 조회해 보유종목을 전부 모은다. 순자산금액/
|
||||
# 총평가손익 등 계좌 합계 필드는 "보유한 잔고를 모두 조회한 이후"의 마지막 페이지
|
||||
# 응답에만 정상 값이 채워지므로(공식 문서 기재) 그 페이지의 Output_0을 쓴다.
|
||||
resp_headers = getattr(self._last_response, 'headers', {}) or {}
|
||||
next_cts = resp_headers.get('cts')
|
||||
next_cts_flag = resp_headers.get('cts_flag')
|
||||
if next_cts_flag == "Y" and next_cts:
|
||||
time.sleep(0.5)
|
||||
next_account = self.get_balance(next_cts, "Y")
|
||||
if next_account is None:
|
||||
return None
|
||||
return finestock.Account(self.account_num, self.account_num_sub, next_account.deposit,
|
||||
next_account.next_deposit, next_account.pay_deposit,
|
||||
holds + next_account.hold)
|
||||
|
||||
return finestock.Account(self.account_num, self.account_num_sub, int(data.get('dca', 0)),
|
||||
int(data.get('nxt_dd_dca', 0)), int(data.get('nxt2_dd_dca', 0)), holds)
|
||||
|
||||
def get_holds(self):
|
||||
balance = self.get_balance()
|
||||
return balance.hold if balance else []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 종목/지수 목록 — NH는 전종목 조회 REST API가 없고 종목마스터 파일(.mst,
|
||||
# CP949 고정길이 바이너리, https://www.nhplug.com/instruments/)로만 제공된다.
|
||||
# ------------------------------------------------------------------
|
||||
def get_stock_list(self, mrkt_tp="0"):
|
||||
print("Nh get_stock_list not supported yet (see instruments/m_new_stock.mst)")
|
||||
return []
|
||||
|
||||
def get_index_list(self):
|
||||
print("Nh get_index_list not supported yet")
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 실시간 (WebSocket)
|
||||
# ------------------------------------------------------------------
|
||||
async def connect(self):
|
||||
self.ws = await websockets.connect(self.DOMAIN_WS)
|
||||
|
||||
async def disconnect(self):
|
||||
self.stop()
|
||||
if self.ws is not None:
|
||||
await self.ws.close()
|
||||
|
||||
def stop(self):
|
||||
self.is_run = False
|
||||
|
||||
async def _send(self, tr_cd, tr_key, status=True):
|
||||
header = {"token": self.access_token, "tr_type": "1" if status else "2"}
|
||||
body = {"tr_cd": tr_cd, "tr_key": tr_key}
|
||||
await self.ws.send(json.dumps({"header": header, "body": body}))
|
||||
|
||||
async def recv_price(self, code, status=True):
|
||||
await self._send("oc", code, status) # 국내주식 실시간체결가(KRX)
|
||||
|
||||
async def recv_index(self, code, status=True):
|
||||
print("Nh recv_index not supported yet")
|
||||
|
||||
async def recv_orderbook(self, code, status=True):
|
||||
await self._send("ob", code, status) # 국내주식 실시간호가(KRX)
|
||||
|
||||
async def recv_trade(self, code, status=True):
|
||||
# 체결통보('d2')는 종목코드가 아닌 userid를 tr_key로 구독하는 계좌 단위 통보라
|
||||
# code 기반의 이 시그니처로는 표현할 수 없다 — 인터페이스 계약만 충족.
|
||||
print("Nh recv_trade not supported yet (userid 기반 체결통보 채널 'd2' 참고)")
|
||||
|
||||
async def run(self):
|
||||
self.is_run = True
|
||||
while self.is_run:
|
||||
try:
|
||||
res = await asyncio.wait_for(self.ws.recv(), timeout=1)
|
||||
res = json.loads(res)
|
||||
header = res.get('header', {})
|
||||
body = res.get('body')
|
||||
if not body:
|
||||
continue
|
||||
|
||||
tr_cd = header.get('tr_cd')
|
||||
code = header.get('tr_key', '')
|
||||
if tr_cd in ("oc", "mc", "nc"):
|
||||
self.add_data(self._parse_price(code, body))
|
||||
elif tr_cd in ("ob", "mb", "nb"):
|
||||
self.add_data(self._parse_orderbook(code, body))
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
except ConnectionClosedOK as e:
|
||||
print(f"ConnectionClosedOK: {e}")
|
||||
self.is_run = False
|
||||
except Exception as e:
|
||||
print(f"Exception: {e}")
|
||||
self.is_run = False
|
||||
|
||||
await self.ws.close()
|
||||
|
||||
# 실시간호가 응답필드는 레벨2~10이 관례적 순번(offer, P_, S_, S4_..S10_)으로 붙는다.
|
||||
_ASK_LEVEL_PREFIXES = ('', 'P_', 'S_', 'S4_', 'S5_', 'S6_', 'S7_', 'S8_', 'S9_', 'S10_')
|
||||
|
||||
def _parse_orderbook(self, code, data):
|
||||
sells = [finestock.Hoga(int(data[f'{p}offer']), int(data[f'{p}offerrem'])) for p in self._ASK_LEVEL_PREFIXES]
|
||||
buys = [finestock.Hoga(int(data[f'{p}bid']), int(data[f'{p}bidrem'])) for p in self._ASK_LEVEL_PREFIXES]
|
||||
total_sell = int(data.get('T_offerrem', 0))
|
||||
total_buy = int(data.get('T_bidrem', 0))
|
||||
return finestock.OrderBook(code, total_buy, total_sell, buys, sells)
|
||||
|
||||
def _parse_price(self, code, data):
|
||||
today = datetime.now().strftime('%Y%m%d')
|
||||
price = float(data['price'])
|
||||
volume_amt = int(data.get('value_won', data.get('value', 0)))
|
||||
return finestock.Price(today, code, price, float(data['open']), float(data['high']), float(data['low']),
|
||||
price, int(data['volume']), volume_amt, data.get('time'))
|
||||
@@ -0,0 +1,19 @@
|
||||
from loguru import logger
|
||||
from finestock.nh import Nh
|
||||
|
||||
|
||||
class NhV(Nh):
|
||||
"""
|
||||
NH투자증권 모의투자(Mock). DOMAIN/DOMAIN_WS만 모의투자(moapi) 도메인으로 바뀌며
|
||||
(path.py 참고), 나머지 REST/WS 엔드포인트·필드는 운영과 동일하다.
|
||||
|
||||
접근토큰발급(oauth2/token)은 모의투자에서 제공되지 않아 Nh.oauth()가 항상
|
||||
OAUTH_DOMAIN(운영)을 바라보도록 되어 있고, 여기서도 그대로 상속해 사용한다.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
print("create NhV Components")
|
||||
|
||||
def __del__(self):
|
||||
logger.debug("Destroy NhV Components")
|
||||
@@ -75,6 +75,28 @@ _KIWOOM_V_ = {
|
||||
"DOMAIN_WS": "wss://mockapi.kiwoom.com:10000/api/dostk/websocket",
|
||||
}
|
||||
|
||||
_NH_ = {
|
||||
"DOMAIN": "https://api.nhplug.com:8443",
|
||||
"DOMAIN_WS": "wss://api.nhplug.com:7070",
|
||||
# 접근토큰발급(oauth2/token)은 모의투자 미제공 — 항상 운영 도메인에서만 발급.
|
||||
# NhV(모의투자)에서도 이 값은 바뀌지 않는다.
|
||||
"OAUTH_DOMAIN": "https://api.nhplug.com:8443",
|
||||
"OAUTH": "oauth2/token",
|
||||
"ACCOUNT_LIST": "n2/acctinfo",
|
||||
"ORDER_CASH_BUY": "krstock/order/v1/cashBuy",
|
||||
"ORDER_CASH_SELL": "krstock/order/v1/cashSell",
|
||||
"ORDER_CANCEL": "krstock/order/v1/cancel",
|
||||
"ORDER_MODIFY": "krstock/order/v1/modify",
|
||||
"BALANCE": "krstock/inquiry/v1/balance",
|
||||
"PRICE": "krstock/quote/v1/currentPrice",
|
||||
"CHART": "krstock/quote/v1/period",
|
||||
}
|
||||
_NH_V_ = {
|
||||
**_NH_,
|
||||
"DOMAIN": "https://moapi.nhplug.com:8443",
|
||||
"DOMAIN_WS": "wss://moapi.nhplug.com:17070",
|
||||
}
|
||||
|
||||
_API_PATH_ = {
|
||||
"EBest": {**_EBEST_},
|
||||
"LS": {**_LS_},
|
||||
@@ -83,4 +105,6 @@ _API_PATH_ = {
|
||||
"KisV": {**_KIS_V_},
|
||||
"Kiwoom": {**_KIWOOM_},
|
||||
"KiwoomV": {**_KIWOOM_V_},
|
||||
"Nh": {**_NH_},
|
||||
"NhV": {**_NH_V_},
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# finestock 런타임 의존성
|
||||
# (setup.py의 install_requires와 동일하게 유지할 것.
|
||||
# 단, asyncio는 Python 3.4+ 표준 라이브러리이므로 여기에 넣지 않는다.)
|
||||
requests
|
||||
websockets
|
||||
loguru
|
||||
|
||||
# 선택 의존성: 예제 스크립트(example.py, example_async.py 등)에서
|
||||
# .env 파일을 자동으로 로드하고 싶을 때만 설치하면 된다.
|
||||
# pip install -r requirements.txt -r requirements-optional.txt 형태로 분리해도 되고,
|
||||
# 필요 없으면 아래 줄을 지워도 example 스크립트는 정상 동작한다(ImportError를 무시함).
|
||||
python-dotenv
|
||||
@@ -0,0 +1,29 @@
|
||||
from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name='finestock',
|
||||
version='1.0.1.0',
|
||||
description='Korean Stock OpenAPI Package(EBest, KIS, LS) creation written by alshin',
|
||||
author='A.Lok, Shin',
|
||||
author_email='shinalok357@gmail.com',
|
||||
url='https://github.com/shinalok/finestock',
|
||||
install_requires=['websockets', 'requests', 'loguru'],
|
||||
extras_require={
|
||||
# 예제 스크립트(example.py 등)의 .env 자동 로드용 선택 의존성.
|
||||
# 패키지 자체 동작에는 필요 없음.
|
||||
'examples': ['python-dotenv'],
|
||||
},
|
||||
packages=find_packages(exclude=[]),
|
||||
keywords=['ebest', 'kis', 'ls', 'kiwoom', 'openapi', 'stock', 'kr'],
|
||||
python_requires='>=3.7',
|
||||
package_data={},
|
||||
zip_safe=False,
|
||||
classifiers=[
|
||||
'Programming Language :: Python :: 3.7',
|
||||
'Programming Language :: Python :: 3.8',
|
||||
'Programming Language :: Python :: 3.9',
|
||||
'Programming Language :: Python :: 3.10',
|
||||
'Programming Language :: Python :: 3.11',
|
||||
'Programming Language :: Python :: 3.12',
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
import unittest
|
||||
from finestock.model import Price
|
||||
|
||||
class TestPrice(unittest.TestCase):
|
||||
def test_price_creation(self):
|
||||
# workday, code, price, open, high, low, close, volume, volume_amt
|
||||
price = Price.from_values(
|
||||
"20250101", "005930", 70000, 69000, 71000, 68000, 70000, 1000, 70000000
|
||||
)
|
||||
self.assertEqual(price.code, "005930")
|
||||
self.assertEqual(price.price, 70000.0)
|
||||
self.assertEqual(price.volume, 1000)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,58 @@
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
import queue
|
||||
|
||||
# Create verification script
|
||||
class TestRefactoring(unittest.TestCase):
|
||||
def test_lazy_loading(self):
|
||||
# We need to ensure we start fresh
|
||||
if 'finestock' in sys.modules:
|
||||
del sys.modules['finestock']
|
||||
if 'finestock.ls' in sys.modules:
|
||||
del sys.modules['finestock.ls']
|
||||
|
||||
import finestock
|
||||
# Check if ls is imported in sys.modules
|
||||
# Note: submodule might be imported but not bound to finestock.ls if lazy
|
||||
# But here we check sys.modules
|
||||
# Actually, if we just import finestock, it should NOT likely import finestock.ls unless __init__ does it
|
||||
|
||||
# However, due to previous run_command usage or environment, it might strictly be tricky to un-import.
|
||||
# But let's try.
|
||||
# Ideally, we check that accessing finestock.ls raises AttributeError until we create it or import it.
|
||||
|
||||
with self.assertRaises(AttributeError):
|
||||
_ = finestock.ls
|
||||
|
||||
print("PASS: Lazy loading check (finestock.ls not accessible)")
|
||||
|
||||
def test_interfaces_and_queue(self):
|
||||
import finestock
|
||||
from finestock.api_factory import APIProvider
|
||||
from finestock.comm.api_interface import MarketDataProvider, RealtimeProvider
|
||||
|
||||
# Create API
|
||||
ls_api = finestock.create_api(APIProvider.LS)
|
||||
|
||||
self.assertIsInstance(ls_api, MarketDataProvider, "LS should implement MarketDataProvider")
|
||||
self.assertIsInstance(ls_api, RealtimeProvider, "LS should implement RealtimeProvider")
|
||||
print("PASS: Interface implementation check")
|
||||
|
||||
# Test Queue
|
||||
q = queue.Queue()
|
||||
ls_api.set_data_queue(q)
|
||||
|
||||
test_data = {"price": 100}
|
||||
ls_api.add_data(test_data)
|
||||
|
||||
received = q.get(timeout=1)
|
||||
self.assertEqual(received, test_data)
|
||||
print("PASS: Queue injection check")
|
||||
|
||||
# Test absence of make_queue
|
||||
self.assertFalse(hasattr(ls_api, 'make_queue'), "make_queue should be removed")
|
||||
print("PASS: make_queue removal check")
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user