diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a3aa8e1 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c7c3fe --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/finestock.iml b/.idea/finestock.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/finestock.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..cd749fe --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..72932d7 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f1128c4 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index 4aa5596..18819a7 100644 --- a/README.md +++ b/README.md @@ -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") ``` -``` diff --git a/example.py b/example.py new file mode 100644 index 0000000..b9bdad2 --- /dev/null +++ b/example.py @@ -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) diff --git a/example_async.py b/example_async.py new file mode 100644 index 0000000..5ec483a --- /dev/null +++ b/example_async.py @@ -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() \ No newline at end of file diff --git a/example_async_kiwoom.py b/example_async_kiwoom.py new file mode 100644 index 0000000..a1b39ec --- /dev/null +++ b/example_async_kiwoom.py @@ -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") diff --git a/example_async_nh.py b/example_async_nh.py new file mode 100644 index 0000000..56d0dae --- /dev/null +++ b/example_async_nh.py @@ -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") diff --git a/example_async_v1.py b/example_async_v1.py new file mode 100644 index 0000000..b9ec103 --- /dev/null +++ b/example_async_v1.py @@ -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") diff --git a/example_kis.py b/example_kis.py new file mode 100644 index 0000000..f2deda7 --- /dev/null +++ b/example_kis.py @@ -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) diff --git a/example_nh.py b/example_nh.py new file mode 100644 index 0000000..c91b2a7 --- /dev/null +++ b/example_nh.py @@ -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) diff --git a/example_v1.py b/example_v1.py new file mode 100644 index 0000000..a5b5dd0 --- /dev/null +++ b/example_v1.py @@ -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() diff --git a/finestock/api_factory.py b/finestock/api_factory.py index 3866190..a826e91 100644 --- a/finestock/api_factory.py +++ b/finestock/api_factory.py @@ -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") \ No newline at end of file diff --git a/finestock/kis/kis.py b/finestock/kis/kis.py index 8e49bf3..db4eee3 100644 --- a/finestock/kis/kis.py +++ b/finestock/kis/kis.py @@ -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) diff --git a/finestock/nh/__init__.py b/finestock/nh/__init__.py new file mode 100644 index 0000000..18685cc --- /dev/null +++ b/finestock/nh/__init__.py @@ -0,0 +1,2 @@ +from .nh import Nh +from .nh_v import NhV diff --git a/finestock/nh/nh.py b/finestock/nh/nh.py new file mode 100644 index 0000000..c743e13 --- /dev/null +++ b/finestock/nh/nh.py @@ -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')) diff --git a/finestock/nh/nh_v.py b/finestock/nh/nh_v.py new file mode 100644 index 0000000..2bbc721 --- /dev/null +++ b/finestock/nh/nh_v.py @@ -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") diff --git a/finestock/path.py b/finestock/path.py index 7dcbea2..d611612 100644 --- a/finestock/path.py +++ b/finestock/path.py @@ -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_}, } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..46a873a --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..46cc298 --- /dev/null +++ b/setup.py @@ -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', + ], +) \ No newline at end of file diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..84f93c5 --- /dev/null +++ b/tests/test_model.py @@ -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() diff --git a/verify_refactor.py b/verify_refactor.py new file mode 100644 index 0000000..55705fd --- /dev/null +++ b/verify_refactor.py @@ -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()