From bfc1cbf6c2ffb8c84432bbded5be207273558ba7 Mon Sep 17 00:00:00 2001 From: Bogyeong Kim Date: Sat, 30 May 2026 10:18:04 +0900 Subject: [PATCH] Add typed KIS parameter helpers Co-authored-by: multica-agent --- README.md | 18 +++- src/apis/bond.rs | 150 +++++++++++++++++++++++++++ src/apis/domestic_futures_options.rs | 53 ++++++++++ src/apis/domestic_stock.rs | 108 ++++++++++++++++++- src/apis/domestic_stock_realtime.rs | 78 ++++++++++++++ src/credentials.rs | 54 +++++++++- src/lib.rs | 2 +- tests/sdk_core.rs | 133 +++++++++++++++++++++--- 8 files changed, 574 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 6b2ab26..cc094c3 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ current coverage boundary. - `KisClient` builder with explicit real/mock 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 and mock workflows. @@ -36,9 +37,11 @@ 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. @@ -138,6 +141,15 @@ The typed SDK currently exposes: | `execute_bond_realtime_tryitout` | `/tryitout/*` | Domain-scoped inventory execution for 3 listed bond realtime tryitout/mock-contract 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. + The domestic futures/options SDK surface exposes inventory-backed domain methods for all 44 endpoints in these bundled official collections: 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",