Compare commits
5
Commits
a4dceccae1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6183b9e6a8 | ||
|
|
82042c9806 | ||
|
|
dbde24f926 | ||
|
|
0303c5e9f4 | ||
|
|
900a7cd97b |
@@ -0,0 +1,23 @@
|
|||||||
|
# finestock 사용 시 필요한 환경변수 템플릿(브로커 앱키/시크릿/계좌번호 등).
|
||||||
|
# 이 파일을 복사해 .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=
|
||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
# --- finestock 프로젝트 전용 ---
|
||||||
|
|
||||||
|
# Python 빌드/캐시 산출물
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# 가상환경
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# 로그 (자격증명이 로깅될 수 있으므로 반드시 제외)
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 로컬 환경변수/시크릿 (예: app_key, app_secret, access_token)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# 로컬 전용 문서 (원격 저장소에는 올리지 않음)
|
||||||
|
doc/
|
||||||
|
|
||||||
|
# 실키/시크릿이 하드코딩된 적이 있어 정리 후 다시 올릴 예정 (원격 저장소에는 올리지 않음)
|
||||||
|
example*.py
|
||||||
|
|
||||||
|
# --- 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.
|
||||||
@@ -1,24 +1,126 @@
|
|||||||
# finestock
|
# finestock
|
||||||
Korean Stock OpenAPI Package (LS, KIS)
|
Korean Stock OpenAPI Package (EBest, LS, KIS, Kiwoom, NH)
|
||||||
Created by alshin
|
Created by alshin
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
1. [설치](#설치)
|
1. [설치](#설치)
|
||||||
2. [아키텍처](#아키텍처)
|
2. [지원 브로커](#지원-브로커)
|
||||||
3. [사용법](#사용법)
|
3. [환경변수 설정 (.env)](#환경변수-설정-env)
|
||||||
4. [Release Notes](#release-notes)
|
4. [아키텍처](#아키텍처)
|
||||||
5. [License](#license)
|
5. [사용법](#사용법)
|
||||||
|
6. [테스트](#테스트)
|
||||||
|
7. [Release Notes](#release-notes)
|
||||||
|
8. [License](#license)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 설치
|
## 설치
|
||||||
|
|
||||||
|
PyPI에 배포된 버전을 쓰려면:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install finestock
|
pip install finestock
|
||||||
```
|
```
|
||||||
|
|
||||||
|
저장소를 직접 클론해서 최신 소스로 개발/테스트하려면(editable install):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/shinalok/finestock.git
|
||||||
|
cd finestock
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
런타임 의존성은 `websockets`, `requests`, `loguru` 세 개뿐입니다(`requirements.txt` 참고).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 지원 브로커
|
||||||
|
|
||||||
|
`APIProvider` enum(`finestock/api_factory.py`)으로 브로커를 선택합니다. `V`로 끝나는 값은 모의투자(Virtual) 전용입니다.
|
||||||
|
|
||||||
|
| Provider | 브로커 | 실전/모의 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `APIProvider.EBEST` | 이베스트투자증권 | 실전 |
|
||||||
|
| `APIProvider.LS` | LS증권 | 실전 |
|
||||||
|
| `APIProvider.LSV` | LS증권 | 모의투자 |
|
||||||
|
| `APIProvider.KIS` | 한국투자증권 | 실전 |
|
||||||
|
| `APIProvider.KISV` | 한국투자증권 | 모의투자 |
|
||||||
|
| `APIProvider.KIWOOM` | 키움증권 | 실전 |
|
||||||
|
| `APIProvider.KIWOOMV` | 키움증권 | 모의투자 |
|
||||||
|
| `APIProvider.NH` | NH투자증권(나무) | 실전 |
|
||||||
|
| `APIProvider.NHV` | NH투자증권(나무) | 모의투자 |
|
||||||
|
|
||||||
|
```python
|
||||||
|
import finestock
|
||||||
|
from finestock import APIProvider
|
||||||
|
|
||||||
|
api = finestock.create_api(APIProvider.LS)
|
||||||
|
```
|
||||||
|
|
||||||
|
브로커마다 지원하는 메서드 범위가 조금씩 다릅니다(예: NH는 지수 조회 TR이 없고, 전종목 리스트는 REST가 아니라 `.mst` 마스터 파일로만 제공됩니다). 자세한 브로커별 차이는 각 클래스의 docstring(`finestock/{ebest,ls,kis,kiwoom,nh}/*.py`)을 참고하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 환경변수 설정 (.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`만 커밋 대상).
|
||||||
|
|
||||||
|
> ⚠️ 실제 앱키/시크릿을 스크립트에 직접 문자열로 적어두고 커밋하지 마세요. 한 번이라도 원격 저장소(특히 public)에 올라간 값은 즉시 노출된 것으로 간주하고 발급처(브로커 개발자센터)에서 폐기 후 재발급받아야 합니다.
|
||||||
|
|
||||||
|
### 값 로드 방법
|
||||||
|
|
||||||
|
**1) python-dotenv로 자동 로드 (권장)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install python-dotenv
|
||||||
|
```
|
||||||
|
|
||||||
|
스크립트 최상단에서 아래처럼 호출하면 `.env` 값이 `os.environ`에 채워집니다(설치돼 있지 않으면 `ImportError`를 잡아 조용히 건너뛰도록 작성하는 게 일반적입니다).
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# bash
|
||||||
|
export APP_KEY="YOUR_APP_KEY"
|
||||||
|
export APP_SECRET="YOUR_APP_SECRET"
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 아키텍처
|
## 아키텍처
|
||||||
@@ -26,15 +128,22 @@ pip install finestock
|
|||||||
`finestock`은 **파사드 패턴(Facade Pattern)**과 **인터페이스 분리 원칙(ISP)**을 결합하여 설계되었습니다.
|
`finestock`은 **파사드 패턴(Facade Pattern)**과 **인터페이스 분리 원칙(ISP)**을 결합하여 설계되었습니다.
|
||||||
|
|
||||||
### 1. 통합된 상태 관리 (Facade)
|
### 1. 통합된 상태 관리 (Facade)
|
||||||
`create_api`로 생성되는 객체는 인증, 시세, 주문 등 모든 기능을 통합하여 관리합니다. 이를 통해 로그인 세션이나 소켓 연결 상태를 여러 모듈이 공유할 수 있어 사용이 편리합니다.
|
`create_api`로 생성되는 객체는 인증, 시세, 주문, 잔고, 실시간 등 모든 기능을 통합하여 관리합니다. 이를 통해 로그인 세션이나 소켓 연결 상태를 여러 모듈이 공유할 수 있어 사용이 편리합니다.
|
||||||
|
|
||||||
### 2. 인터페이스를 통한 명확한 사용 (ISP)
|
### 2. 인터페이스를 통한 명확한 사용 (ISP)
|
||||||
하나의 거대한 객체이지만, **타입 힌팅(Type Hinting)**을 통해 필요한 기능만 노출하여 안전하게 사용할 수 있습니다.
|
하나의 거대한 객체이지만, **타입 힌팅(Type Hinting)**을 통해 필요한 기능만 노출하여 안전하게 사용할 수 있습니다. `finestock/comm/api_interface.py`에 정의된 세그먼트:
|
||||||
|
|
||||||
- `AuthenticationProvider`: 로그인 및 토큰 관리
|
- `AuthenticationProvider`: 로그인 및 토큰 관리(`oauth`, `set_oauth_info`, `set_access_token`)
|
||||||
- `MarketDataProvider`: 과거 시세(이력) 조회
|
- `MarketDataProvider`: 시세/OHLCV/지수/호가 조회(`get_price`, `get_ohlcv`, `get_ohlcv_min`, `get_index`, `get_orderbook`, ...)
|
||||||
- `RealtimeProvider`: 실시간 시세 수신
|
- `TradingProvider`: 주문 실행/취소(`do_order`, `do_order_cancel`)
|
||||||
- `TradingProvider`: 주식 주문 및 잔고 조회
|
- `RealtimeProvider`: WebSocket 실시간 구독(`set_data_queue`, `recv_price`, `recv_orderbook`, ...)
|
||||||
|
- `AccountProvider`: 계좌 잔고/보유종목 조회(`get_balance`, `get_holds`)
|
||||||
|
- `InfoProvider`: 종목/지수 마스터 목록(`get_stock_list`, `get_index_list`)
|
||||||
|
|
||||||
|
이 인터페이스들의 합집합(`BaseProvider`)을 모든 브로커 클래스가 구현합니다. 타입 힌팅은 IDE 자동완성을 좁히기 위한 것일 뿐, 런타임 제약은 없습니다.
|
||||||
|
|
||||||
|
### 3. 데이터 모델 (`finestock/model/`)
|
||||||
|
브로커가 반환하는 원시 JSON/TR 응답은 파싱되어 공통 `@dataclass(frozen=True)` 타입(`Price`, `OrderBook`, `Hold`, `Account`, `Order`, `Trade`, `Stock`, `Index`)으로 통일됩니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -58,11 +167,22 @@ if isinstance(api, AuthenticationProvider):
|
|||||||
# 3. 데이터 조회 (MarketDataProvider 인터페이스 활용)
|
# 3. 데이터 조회 (MarketDataProvider 인터페이스 활용)
|
||||||
if isinstance(api, MarketDataProvider):
|
if isinstance(api, MarketDataProvider):
|
||||||
# IDE에서 get_ohlcv 등 시세 관련 메서드만 자동완성됨
|
# IDE에서 get_ohlcv 등 시세 관련 메서드만 자동완성됨
|
||||||
df = api.get_ohlcv("005930", frdate="20250101", todate="20250110")
|
ohlcvs = api.get_ohlcv("005930", frdate="20260101", todate="20260110")
|
||||||
print(df)
|
print(ohlcvs)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 실시간 데이터 (Queue Injection)
|
### 2. 계좌 조회 (AccountProvider)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from finestock.comm.api_interface import AccountProvider
|
||||||
|
|
||||||
|
if isinstance(api, AccountProvider):
|
||||||
|
account = api.get_balance()
|
||||||
|
if account:
|
||||||
|
print(account.deposit, len(account.hold))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 실시간 데이터 (Queue Injection)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import queue
|
import queue
|
||||||
@@ -75,10 +195,12 @@ if isinstance(api, RealtimeProvider):
|
|||||||
# 2. 큐 주입
|
# 2. 큐 주입
|
||||||
api.set_data_queue(q)
|
api.set_data_queue(q)
|
||||||
|
|
||||||
# 3. 데이터 수신 (비동기 루프 실행 필요)
|
# 3. 데이터 수신 (asyncio 이벤트 루프에서 api.connect()/api.run() 실행 필요)
|
||||||
# ... (자세한 예제는 example_v1.py 참조)
|
# 브로커별 recv_price / recv_orderbook / recv_trade 등을 구독한 뒤
|
||||||
|
# q.get()으로 파싱된 finestock.Price / OrderBook / Trade 객체를 받는다.
|
||||||
|
```
|
||||||
|
|
||||||
### 3. 타입 힌팅 활용 (Type Hinting)
|
### 4. 타입 힌팅 활용 (Type Hinting)
|
||||||
|
|
||||||
IDE에서 각 인터페이스별 메서드만 자동완성되도록 하려면 변수에 타입을 명시할 수 있습니다.
|
IDE에서 각 인터페이스별 메서드만 자동완성되도록 하려면 변수에 타입을 명시할 수 있습니다.
|
||||||
|
|
||||||
@@ -92,7 +214,32 @@ full_api = finestock.create_api(APIProvider.LS)
|
|||||||
market_api: MarketDataProvider = full_api
|
market_api: MarketDataProvider = full_api
|
||||||
|
|
||||||
# market_api. (여기서 get_ohlcv 등만 보임)
|
# market_api. (여기서 get_ohlcv 등만 보임)
|
||||||
df = market_api.get_ohlcv("005930")
|
ohlcvs = market_api.get_ohlcv("005930", frdate="20260101", todate="20260110")
|
||||||
```
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m unittest discover tests # 전체 테스트
|
||||||
|
python -m unittest tests.test_model -v # 특정 모듈만
|
||||||
|
```
|
||||||
|
|
||||||
|
`pytest` 설정은 없으며 표준 라이브러리 `unittest`를 사용합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release Notes
|
||||||
|
|
||||||
|
전체 변경 이력은 [CHANGELOG.md](./CHANGELOG.md)를 참고하세요. 최근 주요 변경:
|
||||||
|
|
||||||
|
- **1.0.1.0**: `MarketDataProvider`에 `get_price(code)` 추가(당일 OHLCV를 단일 `Price`로 반환)
|
||||||
|
- **1.0.0.1**: `APIFactory` lazy loading 도입, 인터페이스 분리(ISP) 리팩터링, 메서드 네이밍 PEP 8 표준화
|
||||||
|
- (개발 중) NH투자증권(나무) 브로커 지원 추가 — `APIProvider.NH` / `APIProvider.NHV`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
별도의 LICENSE 파일은 아직 저장소에 포함되어 있지 않습니다. 라이선스 조건이 필요하다면 저장소 관리자(`shinalok`)에게 문의해 주세요.
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ class APIProvider(Enum):
|
|||||||
KISV = "KISV"
|
KISV = "KISV"
|
||||||
KIWOOM = "KIWOOM"
|
KIWOOM = "KIWOOM"
|
||||||
KIWOOMV = "KIWOOMV"
|
KIWOOMV = "KIWOOMV"
|
||||||
|
NH = "NH"
|
||||||
|
NHV = "NHV"
|
||||||
|
|
||||||
class APIFactory:
|
class APIFactory:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -33,5 +35,11 @@ class APIFactory:
|
|||||||
elif api_provider == APIProvider.KIWOOMV:
|
elif api_provider == APIProvider.KIWOOMV:
|
||||||
from .kiwoom import KiwoomV
|
from .kiwoom import KiwoomV
|
||||||
return 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:
|
else:
|
||||||
raise ValueError("Unsupported API provider")
|
raise ValueError("Unsupported API provider")
|
||||||
+21
-2
@@ -20,8 +20,8 @@ class Kis(API):
|
|||||||
"appkey": self.app_key,
|
"appkey": self.app_key,
|
||||||
"appsecret": self.app_secret
|
"appsecret": self.app_secret
|
||||||
}
|
}
|
||||||
data = json.dumps(data)
|
header = {"Content-Type": "application/json; charset=UTF-8"}
|
||||||
return super().oauth(data=data)
|
return super().oauth(header=header, data=json.dumps(data))
|
||||||
|
|
||||||
def approval(self):
|
def approval(self):
|
||||||
header = self.headers.copy()
|
header = self.headers.copy()
|
||||||
@@ -67,6 +67,11 @@ class Kis(API):
|
|||||||
|
|
||||||
return ohlcvs
|
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')):
|
def get_index(self, code, frdate=datetime.now().strftime('%Y%m%d'), todate=datetime.now().strftime('%Y%m%d')):
|
||||||
header = self.headers.copy()
|
header = self.headers.copy()
|
||||||
header["tr_id"] = "FHKUP03500100"
|
header["tr_id"] = "FHKUP03500100"
|
||||||
@@ -88,6 +93,11 @@ class Kis(API):
|
|||||||
|
|
||||||
return ohlcvs
|
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):
|
def get_orderbook(self, code):
|
||||||
header = self.headers.copy()
|
header = self.headers.copy()
|
||||||
header["tr_id"] = "FHKST01010200"
|
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"]),
|
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)
|
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):
|
def do_order(self, code, buy_flag, price, qty):
|
||||||
url = f"{self.DOMAIN}/{self.ORDER}"
|
url = f"{self.DOMAIN}/{self.ORDER}"
|
||||||
header = self.headers.copy()
|
header = self.headers.copy()
|
||||||
@@ -180,6 +194,11 @@ class Kis(API):
|
|||||||
def get_index_list(self):
|
def get_index_list(self):
|
||||||
print("Kis not supported")
|
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):
|
async def connect(self):
|
||||||
self.approval()
|
self.approval()
|
||||||
self.ws = await websockets.connect(self.DOMAIN_WS)
|
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",
|
"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_ = {
|
_API_PATH_ = {
|
||||||
"EBest": {**_EBEST_},
|
"EBest": {**_EBEST_},
|
||||||
"LS": {**_LS_},
|
"LS": {**_LS_},
|
||||||
@@ -83,4 +105,6 @@ _API_PATH_ = {
|
|||||||
"KisV": {**_KIS_V_},
|
"KisV": {**_KIS_V_},
|
||||||
"Kiwoom": {**_KIWOOM_},
|
"Kiwoom": {**_KIWOOM_},
|
||||||
"KiwoomV": {**_KIWOOM_V_},
|
"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