diff --git a/Cargo.lock b/Cargo.lock index 9bd1241..81d7859 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -450,7 +450,7 @@ dependencies = [ [[package]] name = "kis-sdk" -version = "0.2.0" +version = "0.2.1" dependencies = [ "axum", "http", diff --git a/Cargo.toml b/Cargo.toml index 996c4cc..61bd7dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kis-sdk" -version = "0.2.0" +version = "0.2.1" edition = "2021" description = "Rust SDK core and mock contract harness for Korea Investment & Securities Open API" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 92c1dca..2196d9d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ ergonomic typed wrappers without changing the current coverage boundary. ## Current Status - Package name: `kis-sdk`. +- Current package version: `0.2.1`. - Crates.io publishing: package metadata is publishable, but the crate has not been published yet. Actual upload still requires an authorized `v*.*.*` tag, the `crates-io` environment gate, and `CARGO_REGISTRY_TOKEN`. @@ -25,7 +26,8 @@ ergonomic typed wrappers without changing the current coverage boundary. - `KisClient` builder with explicit environment selection and shared `reqwest` client reuse. -- Redacted `AppCredentials`, `Account`, and `SecretString` helpers. +- Redacted `AppCredentials`, `Account`, `AccountProductCode`, and + `SecretString` helpers. - OAuth token issuance, token revoke, and WebSocket approval-key issuance, with in-memory token reuse and static bearer token injection for tests. - Typed domestic stock methods for quotation price, balance inquiry, and cash @@ -34,9 +36,11 @@ ergonomic typed wrappers without changing the current coverage boundary. trading/account, quotation, market-analysis, and realtime-quotation collections. - Domain-scoped domestic futures/options inventory methods for 44 - order/account, quotation, and realtime quotation endpoints. + order/account, quotation, and realtime quotation endpoints, with typed + operation-id newtypes for safer call sites. - Domain-scoped inventory helpers for 29 domestic stock realtime tryitout - endpoints and 18 listed bond endpoints. + endpoints and 18 listed bond endpoints, with typed operation-id newtypes + available alongside the legacy string constants. - Collection-specific overseas futures/options inventory wrapper covering all 35 order/account, quotation, and realtime endpoints from the bundled official inventory. @@ -71,7 +75,7 @@ kis-sdk = "0.2" ```rust use kis_sdk::{ - apis::domestic_stock::InquirePriceRequest, + apis::domestic_stock::{DomesticStockMarketDivision, InquirePriceRequest}, config::Environment, credentials::AppCredentials, KisClient, @@ -87,7 +91,10 @@ async fn main() -> Result<(), kis_sdk::KisError> { .build()?; let quote = client - .inquire_domestic_stock_price(&InquirePriceRequest::new("005930")) + .inquire_domestic_stock_price(&InquirePriceRequest::with_market( + DomesticStockMarketDivision::Stock, + "005930", + )) .await?; assert!(quote.is_success()); @@ -121,6 +128,22 @@ The typed SDK currently exposes: | `execute_bond_realtime_tryitout` | `/tryitout/*` | Domain-scoped inventory execution for 3 listed bond realtime tryitout endpoints. This is not a live WebSocket subscription API. | | `execute_overseas_futures_options` | 35 overseas futures/options inventory endpoints | Collection-specific wrapper keyed by `OverseasFuturesOptionsEndpoint`; all bundled endpoints are real-only, required fields are validated from inventory, and real trading mutations are locally blocked. | +For new call sites, prefer the typed variants where they exist: +`Account::domestic_stock`, `InquirePriceRequest::with_market`, +`CashOrderRequest::with_order_division`, +`execute_domestic_stock_realtime_tryitout_operation`, +`execute_bond_*_operation`, and +`execute_domestic_futures_options_operation`. The older `String` fields, +string constants, and `&str` operation-id methods remain available for +compatibility. + +Typed helpers still serialize to the exact KIS wire values. For example, +`AccountProductCode::DomesticStock` serializes to `01`, +`DomesticStockMarketDivision::Stock` serializes to `J`, and +`CashOrderDivision::Limit` serializes to `00`. Operation newtypes expose their +stable inventory operation id through `operation_id()` and reject out-of-scope +strings through `FromStr`. + The domestic futures/options SDK surface exposes inventory-backed domain methods for all 44 endpoints in these bundled official collections: diff --git a/docs/release/crates-publish.md b/docs/release/crates-publish.md index ea84029..7657620 100644 --- a/docs/release/crates-publish.md +++ b/docs/release/crates-publish.md @@ -48,7 +48,7 @@ Expected successful guard output includes the package name and version, for exam ```text Publishable crates.io package(s): -- kis-sdk 0.2.0 +- kis-sdk 0.2.1 ``` If a release workflow succeeds but crates.io does not show the crate, check the publish job log for the guard output, the `Publish crate` step, and the action's crates.io propagation check. A missing guard output or an empty action package list indicates the publish path did not prove that a workspace package was publishable. diff --git a/docs/usage-ko.md b/docs/usage-ko.md index 84d3e19..57647a6 100644 --- a/docs/usage-ko.md +++ b/docs/usage-ko.md @@ -113,11 +113,14 @@ WebSocket session, subscription, reconnect, message decoding을 관리하지 ## 국내주식 현재가 조회 ```rust -use kis_sdk::apis::domestic_stock::InquirePriceRequest; +use kis_sdk::apis::domestic_stock::{DomesticStockMarketDivision, InquirePriceRequest}; async fn inquire_price(client: &kis_sdk::KisClient) -> Result<(), kis_sdk::KisError> { let response = client - .inquire_domestic_stock_price(&InquirePriceRequest::new("005930")) + .inquire_domestic_stock_price(&InquirePriceRequest::with_market( + DomesticStockMarketDivision::Stock, + "005930", + )) .await?; if response.is_success() { @@ -131,6 +134,22 @@ async fn inquire_price(client: &kis_sdk::KisClient) -> Result<(), kis_sdk::KisEr 현재 응답 `output`은 provider field를 `serde_json::Value`로 보존합니다. 넓은 범위의 typed response struct는 후속 작업에서 점진적으로 추가될 수 있습니다. +## Typed KIS 코드 Helper 우선 사용 + +SDK가 typed helper를 제공하는 곳에서는 고정 KIS 문자열을 직접 입력하지 않는 +경로를 우선 사용하세요. typed 값은 provider가 요구하는 wire value를 그대로 +직렬화합니다. + +- `Account::domestic_stock(...)`는 계좌 상품 코드 `01`을 채웁니다. +- `DomesticStockMarketDivision::Stock`은 `J`로 직렬화됩니다. +- `CashOrderDivision::Limit`은 `00`으로 직렬화됩니다. +- 채권, 국내주식 realtime, 국내선물옵션 operation newtype은 + `operation_id()`로 stable inventory id를 노출하고, `FromStr`에서 scope 밖 + 문자열을 거부합니다. + +기존 문자열 상수와 `&str` 기반 메서드는 호환성을 위해 유지됩니다. 새 호출부는 +typed 생성자와 `*_operation` 메서드를 우선 사용하세요. + ## Inventory Endpoint 호출 공식 inventory에 포함된 endpoint는 stable operation id와 @@ -230,7 +249,9 @@ trading mutation은 network I/O 전에 `KisError::LiveTradingDisabled`를 ```rust use kis_sdk::{ - apis::domestic_futures_options::QUOTATION_OPERATION_IDS, + apis::domestic_futures_options::{ + DomesticFuturesOptionsOperation, QUOTATION_OPERATION_IDS, + }, endpoint::InventoryRequest, }; use serde_json::json; @@ -238,9 +259,10 @@ use serde_json::json; async fn domestic_futures_options_quote( client: &kis_sdk::KisClient, ) -> Result<(), kis_sdk::KisError> { + let operation = DomesticFuturesOptionsOperation::from_static(QUOTATION_OPERATION_IDS[0])?; let response = client - .execute_domestic_futures_options_quotation::( - QUOTATION_OPERATION_IDS[0], + .execute_domestic_futures_options_operation::( + operation, InventoryRequest::new().query(json!({ "FID_COND_MRKT_DIV_CODE": "F", "FID_INPUT_ISCD": "101W09" @@ -264,15 +286,15 @@ API이며 live WebSocket subscription API가 아닙니다. ```rust use kis_sdk::{ - apis::domestic_stock_realtime, + apis::domestic_stock_realtime::DomesticStockRealtimeOperation, endpoint::InventoryRequest, }; use serde_json::json; async fn realtime_tryitout(client: &kis_sdk::KisClient) -> Result<(), kis_sdk::KisError> { let response = client - .execute_domestic_stock_realtime_tryitout::( - domestic_stock_realtime::REALTIME_TRADE_KRX, + .execute_domestic_stock_realtime_tryitout_operation::( + DomesticStockRealtimeOperation::REALTIME_TRADE_KRX, InventoryRequest::new() .header("approval_key", "test_approval_key") .header("tr_type", "1") @@ -295,15 +317,15 @@ scope가 나뉩니다. 실환경 trading mutation은 network I/O 전에 차단 ```rust use kis_sdk::{ - apis::bond, + apis::bond::BondQuotationOperation, endpoint::InventoryRequest, }; use serde_json::json; async fn bond_price(client: &kis_sdk::KisClient) -> Result<(), kis_sdk::KisError> { let response = client - .execute_bond_quotation::( - bond::INQUIRE_PRICE, + .execute_bond_quotation_operation::( + BondQuotationOperation::INQUIRE_PRICE, InventoryRequest::new().query(json!({ "FID_COND_MRKT_DIV_CODE": "B", "FID_INPUT_ISCD": "KR103502GA34" @@ -328,7 +350,7 @@ use kis_sdk::{ }; async fn inquire_balance(client: &kis_sdk::KisClient) -> Result<(), kis_sdk::KisError> { - let account = Account::new("12345678", "01"); + let account = Account::domestic_stock("12345678"); let response = client .inquire_domestic_stock_balance(&InquireBalanceRequest::new(&account)) .await?; diff --git a/docs/usage.md b/docs/usage.md index 76e2700..d93d097 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -111,11 +111,14 @@ message decoding. ## Inquire A Domestic Stock Price ```rust -use kis_sdk::apis::domestic_stock::InquirePriceRequest; +use kis_sdk::apis::domestic_stock::{DomesticStockMarketDivision, InquirePriceRequest}; async fn inquire_price(client: &kis_sdk::KisClient) -> Result<(), kis_sdk::KisError> { let response = client - .inquire_domestic_stock_price(&InquirePriceRequest::new("005930")) + .inquire_domestic_stock_price(&InquirePriceRequest::with_market( + DomesticStockMarketDivision::Stock, + "005930", + )) .await?; if response.is_success() { @@ -129,6 +132,21 @@ async fn inquire_price(client: &kis_sdk::KisClient) -> Result<(), kis_sdk::KisEr The current output type preserves provider fields as `serde_json::Value` so the SDK can expose the endpoint before broad typed response structs are finalized. +## Prefer Typed KIS Code Helpers + +Prefer typed helpers where the SDK exposes them. They keep call sites away from +mistyped fixed KIS strings while preserving the provider wire values: + +- `Account::domestic_stock(...)` fills account product code `01`. +- `DomesticStockMarketDivision::Stock` serializes to `J`. +- `CashOrderDivision::Limit` serializes to `00`. +- Bond, domestic stock realtime, and domestic futures/options operation + newtypes expose valid inventory ids through `operation_id()` and reject + out-of-scope strings through `FromStr`. + +Existing string constants and `&str` methods remain available for compatibility, +but new code should prefer typed constructors and `*_operation` methods. + ## Call An Inventory Endpoint Use `InventoryCatalog` to inspect generated operation ids and @@ -233,7 +251,9 @@ realtime quotation endpoints. The operation id constants are available from ```rust use kis_sdk::{ - apis::domestic_futures_options::QUOTATION_OPERATION_IDS, + apis::domestic_futures_options::{ + DomesticFuturesOptionsOperation, QUOTATION_OPERATION_IDS, + }, endpoint::InventoryRequest, }; use serde_json::json; @@ -241,9 +261,10 @@ use serde_json::json; async fn domestic_futures_options_quote( client: &kis_sdk::KisClient, ) -> Result<(), kis_sdk::KisError> { + let operation = DomesticFuturesOptionsOperation::from_static(QUOTATION_OPERATION_IDS[0])?; let response = client - .execute_domestic_futures_options_quotation::( - QUOTATION_OPERATION_IDS[0], + .execute_domestic_futures_options_operation::( + operation, InventoryRequest::new().query(json!({ "FID_COND_MRKT_DIV_CODE": "F", "FID_INPUT_ISCD": "101W09" @@ -270,15 +291,15 @@ tryitout endpoints, but they are not live WebSocket subscription APIs. ```rust use kis_sdk::{ - apis::domestic_stock_realtime, + apis::domestic_stock_realtime::DomesticStockRealtimeOperation, endpoint::InventoryRequest, }; use serde_json::json; async fn realtime_tryitout(client: &kis_sdk::KisClient) -> Result<(), kis_sdk::KisError> { let response = client - .execute_domestic_stock_realtime_tryitout::( - domestic_stock_realtime::REALTIME_TRADE_KRX, + .execute_domestic_stock_realtime_tryitout_operation::( + DomesticStockRealtimeOperation::REALTIME_TRADE_KRX, InventoryRequest::new() .header("approval_key", "test_approval_key") .header("tr_type", "1") @@ -304,15 +325,15 @@ I/O. ```rust use kis_sdk::{ - apis::bond, + apis::bond::BondQuotationOperation, endpoint::InventoryRequest, }; use serde_json::json; async fn bond_price(client: &kis_sdk::KisClient) -> Result<(), kis_sdk::KisError> { let response = client - .execute_bond_quotation::( - bond::INQUIRE_PRICE, + .execute_bond_quotation_operation::( + BondQuotationOperation::INQUIRE_PRICE, InventoryRequest::new().query(json!({ "FID_COND_MRKT_DIV_CODE": "B", "FID_INPUT_ISCD": "KR103502GA34" @@ -334,7 +355,7 @@ use kis_sdk::{ }; async fn inquire_balance(client: &kis_sdk::KisClient) -> Result<(), kis_sdk::KisError> { - let account = Account::new("12345678", "01"); + let account = Account::domestic_stock("12345678"); let response = client .inquire_domestic_stock_balance(&InquireBalanceRequest::new(&account)) .await?; diff --git a/src/apis/bond.rs b/src/apis/bond.rs index ed0f8b0..b73dc51 100644 --- a/src/apis/bond.rs +++ b/src/apis/bond.rs @@ -1,4 +1,5 @@ use serde::de::DeserializeOwned; +use std::{fmt, str::FromStr}; use crate::{ client::{KisClient, KisEnvelope}, @@ -58,7 +59,120 @@ pub const BOND_QUOTATION_OPERATIONS: [&str; 8] = [ pub const BOND_REALTIME_TRYITOUT_OPERATIONS: [&str; 3] = [REALTIME_TRADE, REALTIME_ASKING_PRICE, INDEX_REALTIME_TRADE]; +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub struct BondTradingAccountOperation(&'static str); + +impl BondTradingAccountOperation { + pub const BUY_ORDER: Self = Self(BUY_ORDER); + pub const SELL_ORDER: Self = Self(SELL_ORDER); + pub const REVISE_CANCEL_ORDER: Self = Self(REVISE_CANCEL_ORDER); + pub const INQUIRE_REVERSIBLE_CANCELABLE_ORDERS: Self = + Self(INQUIRE_REVERSIBLE_CANCELABLE_ORDERS); + pub const INQUIRE_DAILY_EXECUTIONS: Self = Self(INQUIRE_DAILY_EXECUTIONS); + pub const INQUIRE_BALANCE: Self = Self(INQUIRE_BALANCE); + pub const INQUIRE_BUYABLE_ORDER: Self = Self(INQUIRE_BUYABLE_ORDER); + + pub fn operation_id(self) -> &'static str { + self.0 + } +} + +impl fmt::Display for BondTradingAccountOperation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.operation_id()) + } +} + +impl FromStr for BondTradingAccountOperation { + type Err = KisError; + + fn from_str(value: &str) -> Result { + typed_operation_id( + value, + &BOND_TRADING_ACCOUNT_OPERATIONS, + "bond trading/account", + ) + .map(Self) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub struct BondQuotationOperation(&'static str); + +impl BondQuotationOperation { + pub const INQUIRE_ASKING_PRICE: Self = Self(INQUIRE_ASKING_PRICE); + pub const INQUIRE_PRICE: Self = Self(INQUIRE_PRICE); + pub const INQUIRE_EXECUTIONS: Self = Self(INQUIRE_EXECUTIONS); + pub const INQUIRE_DAILY_PRICE: Self = Self(INQUIRE_DAILY_PRICE); + pub const INQUIRE_DAILY_ITEM_CHART_PRICE: Self = Self(INQUIRE_DAILY_ITEM_CHART_PRICE); + pub const INQUIRE_AVG_UNIT: Self = Self(INQUIRE_AVG_UNIT); + pub const INQUIRE_ISSUE_INFO: Self = Self(INQUIRE_ISSUE_INFO); + pub const SEARCH_BOND_INFO: Self = Self(SEARCH_BOND_INFO); + + pub fn operation_id(self) -> &'static str { + self.0 + } +} + +impl fmt::Display for BondQuotationOperation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.operation_id()) + } +} + +impl FromStr for BondQuotationOperation { + type Err = KisError; + + fn from_str(value: &str) -> Result { + typed_operation_id(value, &BOND_QUOTATION_OPERATIONS, "bond quotation").map(Self) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub struct BondRealtimeOperation(&'static str); + +impl BondRealtimeOperation { + pub const REALTIME_TRADE: Self = Self(REALTIME_TRADE); + pub const REALTIME_ASKING_PRICE: Self = Self(REALTIME_ASKING_PRICE); + pub const INDEX_REALTIME_TRADE: Self = Self(INDEX_REALTIME_TRADE); + + pub fn operation_id(self) -> &'static str { + self.0 + } +} + +impl fmt::Display for BondRealtimeOperation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.operation_id()) + } +} + +impl FromStr for BondRealtimeOperation { + type Err = KisError; + + fn from_str(value: &str) -> Result { + typed_operation_id( + value, + &BOND_REALTIME_TRYITOUT_OPERATIONS, + "bond realtime tryitout", + ) + .map(Self) + } +} + impl KisClient { + pub async fn execute_bond_trading_account_operation( + &self, + operation: BondTradingAccountOperation, + request: InventoryRequest, + ) -> Result, KisError> + where + T: DeserializeOwned, + { + self.execute_bond_trading_account(operation.operation_id(), request) + .await + } + pub async fn execute_bond_trading_account( &self, operation_id: &str, @@ -75,6 +189,18 @@ impl KisClient { self.execute_inventory(operation_id, request).await } + pub async fn execute_bond_quotation_operation( + &self, + operation: BondQuotationOperation, + request: InventoryRequest, + ) -> Result, KisError> + where + T: DeserializeOwned, + { + self.execute_bond_quotation(operation.operation_id(), request) + .await + } + pub async fn execute_bond_quotation( &self, operation_id: &str, @@ -87,6 +213,18 @@ impl KisClient { self.execute_inventory(operation_id, request).await } + pub async fn execute_bond_realtime_tryitout_operation( + &self, + operation: BondRealtimeOperation, + request: InventoryRequest, + ) -> Result, KisError> + where + T: DeserializeOwned, + { + self.execute_bond_realtime_tryitout(operation.operation_id(), request) + .await + } + pub async fn execute_bond_realtime_tryitout( &self, operation_id: &str, @@ -104,6 +242,18 @@ impl KisClient { } } +fn typed_operation_id( + operation_id: &str, + allowed: &[&'static str], + label: &str, +) -> Result<&'static str, KisError> { + allowed + .iter() + .copied() + .find(|candidate| *candidate == operation_id) + .ok_or_else(|| KisError::Validation(format!("{operation_id} is not a {label} operation"))) +} + fn ensure_operation(operation_id: &str, allowed: &[&str], label: &str) -> Result<(), KisError> { if allowed.contains(&operation_id) { Ok(()) diff --git a/src/apis/domestic_futures_options.rs b/src/apis/domestic_futures_options.rs index d5d62e2..064a60a 100644 --- a/src/apis/domestic_futures_options.rs +++ b/src/apis/domestic_futures_options.rs @@ -1,4 +1,5 @@ use serde::de::DeserializeOwned; +use std::{fmt, str::FromStr}; use crate::{ client::{KisClient, KisEnvelope}, @@ -66,7 +67,59 @@ pub fn operation_ids() -> impl Iterator { .chain(REALTIME_QUOTATION_OPERATION_IDS) } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub struct DomesticFuturesOptionsOperation(&'static str); + +impl DomesticFuturesOptionsOperation { + pub fn from_static(operation_id: &'static str) -> Result { + if operation_ids().any(|candidate| candidate == operation_id) { + Ok(Self(operation_id)) + } else { + Err(KisError::Validation(format!( + "operation id {operation_id} is not a domestic futures/options endpoint" + ))) + } + } + + pub fn operation_id(self) -> &'static str { + self.0 + } +} + +impl fmt::Display for DomesticFuturesOptionsOperation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.operation_id()) + } +} + +impl FromStr for DomesticFuturesOptionsOperation { + type Err = KisError; + + fn from_str(value: &str) -> Result { + operation_ids() + .find(|candidate| *candidate == value) + .map(Self) + .ok_or_else(|| { + KisError::Validation(format!( + "operation id {value} is not a domestic futures/options endpoint" + )) + }) + } +} + impl KisClient { + pub async fn execute_domestic_futures_options_operation( + &self, + operation: DomesticFuturesOptionsOperation, + request: InventoryRequest, + ) -> Result, KisError> + where + T: DeserializeOwned, + { + self.execute_domestic_futures_options(operation.operation_id(), request) + .await + } + pub async fn execute_domestic_futures_options( &self, operation_id: &str, diff --git a/src/apis/domestic_stock.rs b/src/apis/domestic_stock.rs index b305f1d..9444fe4 100644 --- a/src/apis/domestic_stock.rs +++ b/src/apis/domestic_stock.rs @@ -2,6 +2,7 @@ use http::Method; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::{fmt, str::FromStr}; use crate::{ client::{KisClient, KisEnvelope}, @@ -51,6 +52,88 @@ const ORDER_CASH: EndpointSpec = EndpointSpec { operation_kind: OperationKind::TradingMutation, }; +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum DomesticStockMarketDivision { + Stock, +} + +impl DomesticStockMarketDivision { + pub fn as_str(self) -> &'static str { + match self { + Self::Stock => "J", + } + } +} + +impl fmt::Display for DomesticStockMarketDivision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for DomesticStockMarketDivision { + type Err = KisError; + + fn from_str(value: &str) -> Result { + match value { + "J" => Ok(Self::Stock), + other => Err(KisError::Validation(format!( + "{other} is not a supported domestic stock market division" + ))), + } + } +} + +impl Serialize for DomesticStockMarketDivision { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum CashOrderDivision { + Limit, +} + +impl CashOrderDivision { + pub fn as_str(self) -> &'static str { + match self { + Self::Limit => "00", + } + } +} + +impl fmt::Display for CashOrderDivision { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for CashOrderDivision { + type Err = KisError; + + fn from_str(value: &str) -> Result { + match value { + "00" => Ok(Self::Limit), + other => Err(KisError::Validation(format!( + "{other} is not a supported domestic cash order division" + ))), + } + } +} + +impl Serialize for CashOrderDivision { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + #[derive(Clone, Debug, Serialize)] pub struct InquirePriceRequest { #[serde(rename = "FID_COND_MRKT_DIV_CODE")] @@ -61,8 +144,12 @@ pub struct InquirePriceRequest { impl InquirePriceRequest { pub fn new(stock_code: impl Into) -> Self { + Self::with_market(DomesticStockMarketDivision::Stock, stock_code) + } + + pub fn with_market(market: DomesticStockMarketDivision, stock_code: impl Into) -> Self { Self { - market_division_code: "J".to_string(), + market_division_code: market.as_str().to_string(), stock_code: stock_code.into(), } } @@ -162,7 +249,24 @@ impl CashOrderRequest { cano: account.cano().to_string(), account_product_code: account.product_code().to_string(), product_number: stock_code.into(), - order_division: "00".to_string(), + order_division: CashOrderDivision::Limit.as_str().to_string(), + order_quantity: quantity.to_string(), + order_unit_price: price.to_string(), + } + } + + pub fn with_order_division( + account: &Account, + stock_code: impl Into, + order_division: CashOrderDivision, + quantity: u64, + price: u64, + ) -> Self { + Self { + cano: account.cano().to_string(), + account_product_code: account.product_code().to_string(), + product_number: stock_code.into(), + order_division: order_division.as_str().to_string(), order_quantity: quantity.to_string(), order_unit_price: price.to_string(), } diff --git a/src/apis/domestic_stock_realtime.rs b/src/apis/domestic_stock_realtime.rs index a5398c3..c707805 100644 --- a/src/apis/domestic_stock_realtime.rs +++ b/src/apis/domestic_stock_realtime.rs @@ -1,4 +1,5 @@ use serde::de::DeserializeOwned; +use std::{fmt, str::FromStr}; use crate::{ client::{KisClient, KisEnvelope}, @@ -88,7 +89,84 @@ pub const DOMESTIC_STOCK_REALTIME_TRYITOUT_OPERATIONS: [&str; 29] = [ REALTIME_MARKET_OPERATION_NXT, ]; +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub struct DomesticStockRealtimeOperation(&'static str); + +impl DomesticStockRealtimeOperation { + pub const REALTIME_TRADE_KRX: Self = Self(REALTIME_TRADE_KRX); + pub const REALTIME_ASKING_PRICE_KRX: Self = Self(REALTIME_ASKING_PRICE_KRX); + pub const REALTIME_EXECUTION_NOTICE: Self = Self(REALTIME_EXECUTION_NOTICE); + pub const REALTIME_EXPECTED_EXECUTION_KRX: Self = Self(REALTIME_EXPECTED_EXECUTION_KRX); + pub const REALTIME_MEMBER_KRX: Self = Self(REALTIME_MEMBER_KRX); + pub const REALTIME_PROGRAM_TRADE_KRX: Self = Self(REALTIME_PROGRAM_TRADE_KRX); + pub const REALTIME_MARKET_OPERATION_KRX: Self = Self(REALTIME_MARKET_OPERATION_KRX); + pub const AFTER_HOURS_REALTIME_ASKING_PRICE_KRX: Self = + Self(AFTER_HOURS_REALTIME_ASKING_PRICE_KRX); + pub const AFTER_HOURS_REALTIME_TRADE_KRX: Self = Self(AFTER_HOURS_REALTIME_TRADE_KRX); + pub const AFTER_HOURS_EXPECTED_EXECUTION_KRX: Self = Self(AFTER_HOURS_EXPECTED_EXECUTION_KRX); + pub const INDEX_REALTIME_TRADE: Self = Self(INDEX_REALTIME_TRADE); + pub const INDEX_EXPECTED_EXECUTION: Self = Self(INDEX_EXPECTED_EXECUTION); + pub const INDEX_PROGRAM_TRADE: Self = Self(INDEX_PROGRAM_TRADE); + pub const ELW_REALTIME_ASKING_PRICE: Self = Self(ELW_REALTIME_ASKING_PRICE); + pub const ELW_REALTIME_TRADE: Self = Self(ELW_REALTIME_TRADE); + pub const ELW_EXPECTED_EXECUTION: Self = Self(ELW_EXPECTED_EXECUTION); + pub const ETF_NAV_TREND: Self = Self(ETF_NAV_TREND); + pub const REALTIME_TRADE_INTEGRATED: Self = Self(REALTIME_TRADE_INTEGRATED); + pub const REALTIME_ASKING_PRICE_INTEGRATED: Self = Self(REALTIME_ASKING_PRICE_INTEGRATED); + pub const REALTIME_EXPECTED_EXECUTION_INTEGRATED: Self = + Self(REALTIME_EXPECTED_EXECUTION_INTEGRATED); + pub const REALTIME_MEMBER_INTEGRATED: Self = Self(REALTIME_MEMBER_INTEGRATED); + pub const REALTIME_PROGRAM_TRADE_INTEGRATED: Self = Self(REALTIME_PROGRAM_TRADE_INTEGRATED); + pub const REALTIME_MARKET_OPERATION_INTEGRATED: Self = + Self(REALTIME_MARKET_OPERATION_INTEGRATED); + pub const REALTIME_TRADE_NXT: Self = Self(REALTIME_TRADE_NXT); + pub const REALTIME_ASKING_PRICE_NXT: Self = Self(REALTIME_ASKING_PRICE_NXT); + pub const REALTIME_EXPECTED_EXECUTION_NXT: Self = Self(REALTIME_EXPECTED_EXECUTION_NXT); + pub const REALTIME_MEMBER_NXT: Self = Self(REALTIME_MEMBER_NXT); + pub const REALTIME_PROGRAM_TRADE_NXT: Self = Self(REALTIME_PROGRAM_TRADE_NXT); + pub const REALTIME_MARKET_OPERATION_NXT: Self = Self(REALTIME_MARKET_OPERATION_NXT); + + pub fn operation_id(self) -> &'static str { + self.0 + } +} + +impl fmt::Display for DomesticStockRealtimeOperation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.operation_id()) + } +} + +impl FromStr for DomesticStockRealtimeOperation { + type Err = KisError; + + fn from_str(value: &str) -> Result { + DOMESTIC_STOCK_REALTIME_TRYITOUT_OPERATIONS + .iter() + .copied() + .find(|candidate| *candidate == value) + .map(Self) + .ok_or_else(|| { + KisError::Validation(format!( + "{value} is not a domestic stock realtime tryitout operation" + )) + }) + } +} + impl KisClient { + pub async fn execute_domestic_stock_realtime_tryitout_operation( + &self, + operation: DomesticStockRealtimeOperation, + request: InventoryRequest, + ) -> Result, KisError> + where + T: DeserializeOwned, + { + self.execute_domestic_stock_realtime_tryitout(operation.operation_id(), request) + .await + } + pub async fn execute_domestic_stock_realtime_tryitout( &self, operation_id: &str, diff --git a/src/credentials.rs b/src/credentials.rs index c5dd2cf..e790a93 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -1,4 +1,7 @@ -use std::fmt; +use serde::Serialize; +use std::{fmt, str::FromStr}; + +use crate::error::KisError; #[derive(Clone, Eq, PartialEq)] pub struct SecretString(String); @@ -42,6 +45,47 @@ impl AppCredentials { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum AccountProductCode { + DomesticStock, +} + +impl AccountProductCode { + pub fn as_str(self) -> &'static str { + match self { + Self::DomesticStock => "01", + } + } +} + +impl fmt::Display for AccountProductCode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for AccountProductCode { + type Err = KisError; + + fn from_str(value: &str) -> Result { + match value { + "01" => Ok(Self::DomesticStock), + other => Err(KisError::Validation(format!( + "{other} is not a supported account product code" + ))), + } + } +} + +impl Serialize for AccountProductCode { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct Account { cano: SecretString, @@ -56,6 +100,14 @@ impl Account { } } + pub fn with_product_code(cano: impl Into, product_code: AccountProductCode) -> Self { + Self::new(cano, product_code.as_str()) + } + + pub fn domestic_stock(cano: impl Into) -> Self { + Self::with_product_code(cano, AccountProductCode::DomesticStock) + } + pub(crate) fn cano(&self) -> &str { self.cano.expose() } diff --git a/src/lib.rs b/src/lib.rs index 8ec1241..b9a6092 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,5 +13,5 @@ pub use client::{ AccessTokenResponse, KisClient, KisEnvelope, RealtimeApprovalKeyResponse, RevokeTokenResponse, }; pub use config::Environment; -pub use credentials::{Account, AppCredentials, SecretString}; +pub use credentials::{Account, AccountProductCode, AppCredentials, SecretString}; pub use error::KisError; diff --git a/tests/sdk_core.rs b/tests/sdk_core.rs index 29719f0..94f0669 100644 --- a/tests/sdk_core.rs +++ b/tests/sdk_core.rs @@ -6,19 +6,23 @@ use axum::{ use kis_sdk::{ apis::{ bond::{ - self, BOND_QUOTATION_OPERATIONS, BOND_REALTIME_TRYITOUT_OPERATIONS, + self, BondQuotationOperation, BondRealtimeOperation, BondTradingAccountOperation, + BOND_QUOTATION_OPERATIONS, BOND_REALTIME_TRYITOUT_OPERATIONS, BOND_TRADING_ACCOUNT_OPERATIONS, }, domestic_futures_options::{ - operation_ids as domestic_futures_options_operation_ids, QUOTATION_OPERATION_IDS, + operation_ids as domestic_futures_options_operation_ids, + DomesticFuturesOptionsOperation, QUOTATION_OPERATION_IDS, REALTIME_QUOTATION_OPERATION_IDS, TRADING_ACCOUNT_OPERATION_IDS, }, domestic_stock::{ - domestic_stock_rest_endpoints, CashOrderRequest, CashOrderSide, InquireBalanceRequest, - InquirePriceRequest, DOMESTIC_STOCK_REST_COLLECTIONS, - DOMESTIC_STOCK_REST_ENDPOINT_COUNT, + domestic_stock_rest_endpoints, CashOrderDivision, CashOrderRequest, CashOrderSide, + DomesticStockMarketDivision, InquireBalanceRequest, InquirePriceRequest, + DOMESTIC_STOCK_REST_COLLECTIONS, DOMESTIC_STOCK_REST_ENDPOINT_COUNT, + }, + domestic_stock_realtime::{ + self, DomesticStockRealtimeOperation, DOMESTIC_STOCK_REALTIME_TRYITOUT_OPERATIONS, }, - domestic_stock_realtime::{self, DOMESTIC_STOCK_REALTIME_TRYITOUT_OPERATIONS}, overseas_futures_options::OverseasFuturesOptionsEndpoint, overseas_stock::{ OverseasStockEndpoint, MARKET_ANALYSIS_ENDPOINTS, QUOTATION_ENDPOINTS, @@ -27,7 +31,7 @@ use kis_sdk::{ }, config::Environment, contract::EnvironmentSupport, - credentials::{Account, AppCredentials, SecretString}, + credentials::{Account, AccountProductCode, AppCredentials, SecretString}, endpoint::{InventoryCatalog, InventoryEndpointSpec, InventoryRequest, OperationKind}, error::KisError, fallback::FallbackPolicy, @@ -37,6 +41,7 @@ use kis_sdk::{ }; use serde_json::{json, Map, Value}; use std::collections::{BTreeMap, HashSet}; +use std::str::FromStr; use tokio::{net::TcpListener, task::JoinHandle}; #[tokio::test] @@ -294,6 +299,104 @@ fn domestic_realtime_and_bond_domain_operations_cover_target_inventory_collectio ); } +#[test] +fn typed_domestic_stock_values_serialize_and_parse_contract_codes() { + let account = Account::domestic_stock("12345678"); + + let price = InquirePriceRequest::with_market(DomesticStockMarketDivision::Stock, "005930"); + assert_eq!( + serde_json::to_value(&price).expect("price request serializes"), + json!({ + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": "005930" + }) + ); + + let order = CashOrderRequest::with_order_division( + &account, + "005930", + CashOrderDivision::Limit, + 1, + 70000, + ); + assert_eq!( + serde_json::to_value(&order).expect("cash order serializes"), + json!({ + "CANO": "12345678", + "ACNT_PRDT_CD": "01", + "PDNO": "005930", + "ORD_DVSN": "00", + "ORD_QTY": "1", + "ORD_UNPR": "70000" + }) + ); + + assert_eq!( + AccountProductCode::from_str("01").expect("account product parses"), + AccountProductCode::DomesticStock + ); + assert_eq!( + serde_json::to_value(AccountProductCode::DomesticStock) + .expect("account product serializes"), + json!("01") + ); + assert_eq!( + DomesticStockMarketDivision::from_str("J").expect("market division parses"), + DomesticStockMarketDivision::Stock + ); + assert_eq!( + serde_json::to_value(DomesticStockMarketDivision::Stock) + .expect("market division serializes"), + json!("J") + ); + assert_eq!( + CashOrderDivision::from_str("00").expect("cash order division parses"), + CashOrderDivision::Limit + ); + assert_eq!( + serde_json::to_value(CashOrderDivision::Limit).expect("order division serializes"), + json!("00") + ); + assert!(AccountProductCode::from_str("99").is_err()); + assert!(DomesticStockMarketDivision::from_str("K").is_err()); + assert!(CashOrderDivision::from_str("10").is_err()); +} + +#[test] +fn typed_operation_newtypes_parse_and_reject_unknown_operation_ids() { + let bond_quotation = BondQuotationOperation::from_str(bond::INQUIRE_PRICE) + .expect("bond quotation operation parses"); + assert_eq!(bond_quotation.operation_id(), bond::INQUIRE_PRICE); + assert_eq!( + BondTradingAccountOperation::BUY_ORDER.operation_id(), + bond::BUY_ORDER + ); + assert_eq!( + BondRealtimeOperation::REALTIME_TRADE.operation_id(), + bond::REALTIME_TRADE + ); + + let realtime = + DomesticStockRealtimeOperation::from_str(domestic_stock_realtime::REALTIME_TRADE_KRX) + .expect("domestic realtime operation parses"); + assert_eq!( + realtime.operation_id(), + domestic_stock_realtime::REALTIME_TRADE_KRX + ); + + let domestic_future = + DomesticFuturesOptionsOperation::from_str(TRADING_ACCOUNT_OPERATION_IDS[0]) + .expect("domestic futures/options operation parses"); + assert_eq!( + domestic_future.operation_id(), + TRADING_ACCOUNT_OPERATION_IDS[0] + ); + + assert!(BondQuotationOperation::from_str(bond::BUY_ORDER).is_err()); + assert!(DomesticStockRealtimeOperation::from_str(bond::REALTIME_TRADE).is_err()); + assert!(DomesticFuturesOptionsOperation::from_str("unknown.operation").is_err()); +} + #[tokio::test] async fn overseas_stock_execute_calls_mock_supported_price_endpoint() { let server = MockServer::start().await.expect("mock server starts"); @@ -333,8 +436,8 @@ async fn domestic_stock_realtime_tryitout_api_calls_mock_contract_endpoint() { .expect("client builds"); let response = client - .execute_domestic_stock_realtime_tryitout::( - domestic_stock_realtime::REALTIME_TRADE_KRX, + .execute_domestic_stock_realtime_tryitout_operation::( + DomesticStockRealtimeOperation::REALTIME_TRADE_KRX, InventoryRequest::new() .header("approval_key", "test_approval_key") .header("tr_type", "1") @@ -464,8 +567,8 @@ async fn bond_domain_apis_preserve_inventory_validation_and_safety_guards() { .expect("client builds"); let missing_query_error = read_client - .execute_bond_quotation::( - bond::INQUIRE_PRICE, + .execute_bond_quotation_operation::( + BondQuotationOperation::INQUIRE_PRICE, InventoryRequest::new().query(json!({ "FID_COND_MRKT_DIV_CODE": "B" })), @@ -482,8 +585,8 @@ async fn bond_domain_apis_preserve_inventory_validation_and_safety_guards() { .expect("client builds"); let unsupported_mock_error = mock_client - .execute_bond_realtime_tryitout::( - bond::REALTIME_TRADE, + .execute_bond_realtime_tryitout_operation::( + BondRealtimeOperation::REALTIME_TRADE, InventoryRequest::new() .header("approval_key", "test_approval_key") .header("tr_type", "1") @@ -500,8 +603,8 @@ async fn bond_domain_apis_preserve_inventory_validation_and_safety_guards() { )); let live_trading_error = read_client - .execute_bond_trading_account::( - bond::BUY_ORDER, + .execute_bond_trading_account_operation::( + BondTradingAccountOperation::BUY_ORDER, InventoryRequest::new().body(json!({ "ACNT_PRDT_CD": "01", "BOND_ORD_UNPR": "10000",